0%

这是我这几个月来第一次只想把文章发到个人博客上,而不想发到其他平台上。

顺带一提,我是这样看待个人博客与其他平台的:个人博客写的东西,基本都是围绕我自己。只有非常关注我的人,才会看到这些内容。大众看不看得到,无所谓。我就是为了自己而写。我可以不管语法,不管内容,想怎么写怎么写。而在其他平台写的东西,多少有一点宣传的意思在内,会受到种种制约。

前几天,我去发表了一篇个人感想的文章。风格其实和我之前的博文差不多,但是主题更吸引人(尤其是标题),内容稍长。如我所意料地,相对我现在拥有的流量而言,这篇文章收获了空前的关注。

我开心也不开心。开心是因为,总算写的东西有很多人看了。而且,这篇文章很受欢迎,完全在我的意料之中。也就是说,我有估计文章传播力的判断力。真的只是为了提升文章的阅读数据,我可以轻松写出一堆文章来。

不开心的原因就比较复杂了。

上周六晚上,发文章之前,我突然心跳加速,紧张了起来。文章是几周前写的,也早早就决定了要发。我也不知道为什么突然会这样。发完文章,我就立刻去吃饭了,也不敢盯着发送的数据看。吃饭回来一看,文章果然大受关注。第二天,知乎也开始推送我的文章了,也确实引了一大批人。

看到大家的评论,我这才明白我在担心什么。

我那篇文章写得是什么?纯粹写的是我的心路历程,我做了什么,想了什么。我的经历多么可笑,我的想法多么龌龊。我根本不管这么多,只是原原本本地写了下来,然后发了出去。就好像没穿衣服站在街上一样。

当然,我之所以敢这么做,就是因为我已经完全看淡了过去的那些事情。我敢写出来,说明我把过去的那个“我”已经不当成我自己了。我把那段经历,完完全全当成一个商品在售卖。经历是个人组成的一部分。也就是说,我把自己灵魂的一部分割了下来,拿出去卖。

有些人分享经历,是为了获取认同。获得他人的赞,就像获得了他人的鼓励一样。

但是,我把自己的目的写得很明确:主要是为了流量,次要是为了分享。他人的赞,甚至是加油的祝福,对早已熬过去的我来说一点帮助也没有。

甚至我还有层次更高的考虑。我写这篇文章,就是要向大众展示真实的自己。我不怕别人看到自己的缺点。我不会去打造一个完美无缺的人。不论以后我做成了多大的事情,我都只是一个心思很普通的人。我要拿这篇文章去约束未来的自己。

当然,我准备发表这篇文章时,还有更多的考量。这些层次非常高的考量是我潜意识里计划的,我现在一下都总结不出来。我说的很多东西,现在听起来都是吹牛,但我相信过个几年回头来看,我现在说的东西都是必然的预言。

可是,这样的写作可持续吗?

不可能的。

我再怎么如自己所期盼得铁石心肠,也不愿意天天把自己的事情拿出去展示。不管是好的事情,还是坏的事情。讲好的事情,就是拿自己过去的成就吹嘘;讲坏的事情,就是卖惨,博同情。这两件事我都不愿意做。我更希望我的作品受到关注。那些东西是实实在在的应该拿出来展示的东西。

我的思绪有点乱,一下也表达不清我想说什么。好不容易写一次个人博客,我也不想改文章了。我就想想到哪写到哪。总之,以后,我会在分享个人经验的时候更加慎重。我既不要过分卖弄成就,也不要刻意地去塑造一个很惨的形象。发个人经历的文章之前,要更加慎重。

还有一点,我很不开心。对比之下,看技术文章的人寥寥无几,而输出情感的文章倒是喜闻乐见。这里面的道理我肯定懂。受众、花费的精力……很多东西决定了文章有没有人看。我从类型上非常能够理解。但心里就是不好受。

所以想来想去,思绪还是回到了之前那个问题上:我为什么要去做自媒体?

我一直没有单独谈这个问题。只在我个人博客的这个地方这个地方稍微谈了一下。现在可以认真想一下了。

我还确实是一个能不说谎就不说谎的人。哪怕是讲真话没什么意义,我也会毫不在意地表示自己的真实想法。我就是要让自己一直做一个坦荡的人。我在第一篇DL教程开头就讲了,“诲人不如诲己”。写文章,本来就是为了提升我自己的学习效率。也确实,自从开始在自媒体上发文章后,我每周都很固定地会写好教程,学习的效率高了很多。同时,还有几个人说愿意跟着教程学。我也不管是真的假的,只要能帮助到人,我就非常开心。因此,发教程,不管有没有人看,我都照发不误。

这正常地发下去,倒也没什么关系。可是,发完个人经历感想,受到了一定程度的关注后,我反而开始动摇了:“是不是以后都得写这种文章才有人看?”,“我写技术文章还有意义吗?”,之类的想法不断涌现。

我必须得重新审视一下我搞自媒体的意义了。

我向来就讨厌迎合他人,迎合社会,迎合任何人订下的评价标准。开始发自媒体,我还以为是自己终于肯变通一下,肯去迎合一下这个世界的规则了。甚至当时我还嘲笑了自己的不坚定。

但现在我发现,我根本没变。我就是一个无比自傲的人。我就是能够创作出好的东西,我就是能给他人带来价值。凭什么我要去迎合别人的想法?凭什么我要去宣传?我不服气啊。

我之前之所以没想这么多,就是如前面所写的,我一直抱有的是提升自我,顺便分享给他人的心态。

但我一想到要最优化我在自媒体上的名气,我就很不服气。我就是不像刻意去为了提升影响力而提升影响力。我要靠我展示出来的真正的东西来提升影响力。无论是艺术作品,还是教程,还是科研产出,还是其他什么的。我不想花一丝一毫在宣传上。我就只想把自己的东西做到极致,让他人主动来我这里看东西。

没办法,我内心的理性都很清楚,这个世界的规则是怎么样的,自媒体是怎么运行的。理性思考指出,我想要的事情是不太可能的。

这就是一个站着还是跪着的抉择。我没有膝盖,跪不下来,只会站着;我也没有脖子,不会向下看,只会盯着前方看。做不到的事情,就是不想做。

其实,我想搞自媒体,最初的想法就是不希望未来出现这些事。我以后要做游戏,不希望做了好游戏没人玩。所以,我想着是做视频。我的口才好,在某些领域一定能大受欢迎。但我还是害怕,害怕没人看。有人(包括我内心里质疑的声音)这就会说了:“你害怕,不就是做不好吗?还找什么借口?”那我表述得再详细一点。假如我做了自以为100分的东西,客观上能打80分,但是只收获了0.01分的关注度,换你来你受得了吗?而且创作是一个不断进步的过程,可能开始还不太习惯,做着做着就能发挥全部的才能了。这一开始就没了创作热情,还怎么做下去呢?再有,做视频,最重要的其实是时间上的成本。我暂时想不出一个时间成本低、视频节目效果好、有干货的视频项目。所以不做。

那问题又传递下去了。不敢做游戏,所以计划做视频;不敢做视频,所以计划写文章。这才是我最后决定搞文字自媒体的原因。

现在,问题又绕回来了。就是如我所料地,在没有流量的前提下,写得好但是不吸引人的东西,就是没人看。我又不太肯去最优化流量,去想法设法地宣传。这下好了,又陷入死结了。

我发完上一篇个人感想,受到一定程度的欢迎后,我的大脑一下子就想到了这么多,所以一下低沉了起来。我花了大约一小时,才把这些想法组织起来,理解了自己的心理的成因。我的思维也太跳跃了吧。

再仔细一想,问题也很简单:还做不做下去?到了该抉择的时候了。

不做也没什么关系。对我来说,靠实力把名气打响是很简单的。我现在在搞科研,搞得好,自然能收获名誉。这种名气与实力挂钩的领域是我最推崇的。另外,我也可以直接去做游戏。游戏做完了,我觉得做得足够好了,再去想该怎么宣传。不要担心卖不出去,搞好质量就行了。

对,我就是有这样的自信。不靠现在的自媒体,不去刻意宣传,还是刻意把名气打响。

只是到时候不叫座就怪不得别人了。一切都是自己菜。

那要不就还是做下去。不忘初心。

初心是什么?就是分享。发文章是零成本的事情,有人看就是赚到。

也不去做什么宣传,该怎么样就怎么样了。

我的性格就是不适合做自媒体。我讨厌过度拟合一个目标,讨厌为了目的不择手段。在受到他人赞誉的时候也没有那么强的正反馈。

但我还有一个足以忽视其他一切条件的个性:我不相信有我做不好的事情。

一般的人质疑我,我笑一笑也就过去了。问题是我开始自己质疑自己做不好了,这就令我很焦躁了。

说我做不好自媒体,说我不肯去迎合他人,说我太傲慢了。

好,我忍。

我就必须证明一下自己了。

我会再做一段时间自媒体,稍微花点心思去总结一下他人的经验,制定更详细的计划,更有智谋地完成自媒体运营这件事。

同样,我会坚持几个底限:不去刷数据;不去写没有价值的东西;不过度宣传;不影响我的主业。

如果我发现,搞自媒体要花太多精力,影响到我的其他计划的话,我就放弃。否则,我会用这件事来展示我的能力,搞一个有影响的自媒体出来。

其实写这篇文章之前,我心里基本就有了答案。写一遍只是让自己更清醒一点。我可以再次宣告道:请世人见证我的成就。

今天跟喝醉了一样,但我从来不喝酒。所以,也可以这样说,我怎么都喝不醉。

冲了个澡,醒酒了。运营自媒体和个人博客完全是两个东西。自媒体必须从用户的角度来考虑文章题材。我还是按照老计划,先零成本地把技术博客给写好来。之后,用一种处于博客和自媒体的中间形式来运营。偏自媒体的文章中,主要关注两种形式:一篇长篇大作和定期更新长期连载的小体量作品。此外,夹杂一些和大学教育、编程有关的非技术的感想文章。没必要为了文章关注量这种小事担心,做好手中的事就行了。

学习提示

这周要学习四项内容:错误分析、使用不匹配的数据、完成多个任务的学习、端到端学习。

其中,前两项内容是对上一周内容的扩展。学完这些知识后,我们能更好地决定下一步的改进计划。通过处理分布不匹配的数据,我们能够学会如何诊断一种新的问题:数据不匹配问题。之后,我们使用错误分析技术,找到模型具体的错误样例,进一步改进模型。

后两项内容分别是两项深度学习的应用技巧。我们会学习迁移学习、多任务学习这两种处理多个学习任务的方法。我们还会学习如何用深度学习把问题一步到位地解决,而不是分好几个步骤。

课堂笔记

错误分析

分析具体错误

当我们想提升模型的准确率时,一种做法是统计模型输出错误的样例,看看哪类数据更容易让模型出错。

比如,在提升一个小猫分类器的准确率时,我们可以去看看分类器最容易把其他哪种动物错分类成小猫。经过调查后,我们可能会发现一些小狗长得很像小猫,分类器在这些小狗图片上的表现不佳:

这时,我们可以考虑去提升模型在小狗图片上的表现。

但是,在决定朝着某个方向改进模型之前,我们应该先做一个数据分析,看看这样的改进究竟有没有意义。我们可以去统计100张分类错误的开发集图片,看看这些图片里有多少张是小狗。如果小狗图片的数量很小,比如说只有5张,那么无论我们再怎么提升模型辨别小狗的能力,我们顶多把10%的错误率降到9.5%,提升微乎其微;但如果错分为小狗图片的数量很多,比如有50张,那么我们最优情况下可以把错误率从10%降到5%,这个提升就很显著了。

更系统地,我们可以建立一套同时分析多个改进方向的数据分析方法。比如说,在小猫的错误样例中,一些输入样本是很像小猫的小狗,一些输入样本是其他大型猫科动物,一些输入样本过于模糊。我们可以挑一些错误的样例,分别去记录这些错误样例的出现情况:

在这个表格中,我们可以记录每张分类错误的图片是由哪一种错误引起的,并留下一些备注。

调研已有问题的同时,我们还可以顺便去发现一些新的问题。比如我们可能会发现某些错分类的图片加了滤镜。发现这个新问题后,我们可以去表格中新建“滤镜”这一列。

手动分析完所有样例后,我们统计每种错误的百分比,看看改进哪种问题的价值更大。

清理标错的数据

在有监督学习中,标注数据往往是人工完成的,数据的标签有误也是情理之中的事。那么,如果数据中有标错的数据,它们会对模型的表现有什么影响呢?

首先,来看训练集有误的影响。事实上,深度学习算法对随机错误的容忍度很高。如果有少量样本是不小心标错的,那么它们对训练结果几乎没有影响。但是,如果数据中有系统性错误,比如白色的小狗全部被标成了小猫,那问题就大了,因为模型也会学到数据集中这种错误的规律。

接着,我们来看开发集有误的影响。为了确认标错数据的影响,我们应该用刚刚的表格统计法,顺便调查一下标错数据的比例:

在开发集误差不同时,标错数据产生的影响也不同。假设我们分别有一个开发集误差为10%的分类器和一个误差为2%的分类器:

对于第一个分类器,总体占比0.6%的错标数据相对于10%的开发集错误率几乎可以忽略。但是,对于第二个误差为2%的分类器,0.6%的错标数据就显得占比很大了。在这种情况下,假如有同一个模型有两个权重记录点,一个误差为2.1%,一个误差为1.9%。由于误差的存在,我们不好说第二个记录点就优于第一个记录点。回想一下,开发集本来的目的就是帮助我们选择一个在开发集上表现更好的模型。分辨不出更好的模型,开发集就失效了。因此,我们必须要去纠正一下这些开发集中的错标数据。

在纠正错标数据时,我们要注意以下几点:

  • 由于开发集和测试集应来自同一个分布,纠正数据的过程应该在开发集和测试集上同步进行。
  • 不仅要检查算法输出错误的样本中的错标样本,还要考虑那些标注错误却输出正确的样本。
  • 不一定要去训练集上纠正错标样本,因为训练集和开发集/测试集可以来自不同的分布。

吴恩达老师建议道,尽管很多人会因为检查数据这件事很琐碎而不愿意去一个一个检查算法输出错误的样本,但他还是鼓励大家这样做。他在自己领导的深度学习项目中,经常亲自去检查错误样本。检查错误样本往往能够确认算法之后的改进方向,在这件事上花时间绝对是值得的。

快速构建第一个系统,再迭代更新

在面对一个全新的深度学习问题时,我们不应该一上来就花很多时间去开发一个复杂的系统,而是应该按照下面的步骤尽快开始迭代:

  • 快速建立开发集、测试集和评估指标以树立一个目标。
  • 快速构建一个初始的系统。
  • 使用偏差和方差分析、错误分析来获取后续任务的优先级。

简而言之,就是:快速构建第一个系统,再迭代更新。

当然,如果你在这个问题上已经很有经验了,或者这个问题已经有很多的科研文献,那么一上来就使用一套较为复杂却十分成熟的系统也是可以的。

这种快速迭代的思想同样适用于人生中的其他任务。比如,软件开发中,敏捷开发指的就是快速开发出原型,再逐步迭代。同样,我们在计划做一件事时,不必事先就想得面面俱到,可以尽快下手,再逐渐去改良做法。

不匹配的训练集与开发/测试集

在不同分布上训练与测试

到目前为止,我们已经多次学习过,开发集和测试集的分布必须一致,但是它们与训练集的分布不一定要一致。让我们来看一个实际的例子:

假设我们要开发一个小猫分类的手机程序。我们有两批数据,第一批是从网站上爬取的高清图片,共200,000张;第二批是使用手机摄像头拍摄上传的图片,有10,000张。最终,用户在使用我们的手机程序时,也是要通过拍照上传。

现在,有一个问题:该如何划分训练集、测试集、开发集呢?

一种方法是把所有数据混在一起,得到210,000张图片。之后,按照某种比例划分三个集合,比如按照205,000/2,500/2,500的比例划分训练/测试/开发集。

这种方法有一个问题:我们的开发集和测试集中有很多高清图片。但是,用户最终上传的图片可能都不是高清图片,而是模糊的收集摄像图片。在开发集和测试集中混入更简单的高清图片会让评估结果偏好,不能反映模型在实际应用中的真正表现。

另一种方法是只用手机拍摄的图片作为开发集和测试集。我们可以从手机拍摄的图片里选5,000张放进训练集里,剩下各放2,500张到开发/训练集里。这样的话,开发集和测试集就能更好地反映模型在我们所期望的指标上的表现了。

总结来说,如果我们有来自不同分布的数据,我们应该谨慎地划分训练集与开发/测试集,尽可能让开发/测试集只包含我们期待的分布中的数据,哪怕这样做会让训练集和开发/测试集的分布不一致。

不同数据分布下的偏差与方差问题

在之前的学习中,我们一直把机器学习模型的改进问题分为偏差问题和方差问题两种。而在使用不匹配的数据分布后,我们会引入一个新的分布不匹配问题。

还是在刚刚提到的小猫分类模型中,我们用第二种方法设置了分布不一致的训练集和开发/训练集。假设我们得到了1%的训练误差和10%的开发误差。但是,我们使用了不同分布的数据,开发/测试集的数据可能比训练数据要难得多。我们难以分辨更高的开发误差是过拟合导致的,还是开发集比训练集难度更高导致的。

为了区分这两种问题,我们需要划分出一个只评估一种问题的新数据集——训练开发集(Training-dev set)。训练开发集的用法和我们之前用的开发集类似,但是其数据分布和训练集一致,而不参与训练。通过比较模型在训练集和训练开发集上的准确度,我们就能单独评估模型的方差,进而拆分过拟合问题和数据不匹配问题了。

加入了这个数据集后,让我们对几个示例进行改进问题分析。

假设人类在小猫分类上的失误率是0%。现在,有以下几个不同准确率的模型:

误差/样本 1 2 3 4
训练误差 1% 1 10% 10%
训练开发误差 9% 1.5% 11% 11%
开发误差 10% 10 12% 20%
问题诊断 高方差 数据不匹配 高偏差 高偏差、数据不匹配

也就是说,在多出了数据不匹配问题后,我们可以通过加入一个训练开发集来区分不同的问题。

当然,数据不匹配不一定会加大误差。如果开发/测试集上的数据更加简单,模型有可能取得比训练集还低的误差。

结合上周的知识,总结一下,考虑数据不匹配问题后,我们应该建立如下的表格:

首先,我们要知道训练集上人类的表现,以此为贝叶斯误差的一个估计。之后,我们要测训练误差和训练开发误差。训练误差和人类表现之间的差距为可规避偏差,训练开发误差和训练误差之间的差距为方差。最后,我们计算开发/测试集误差,这个误差和训练开发误差之间的差距为数据不匹配造成的差距。

一般来说,只把上述内容填入表格即可明确当前模型存在的问题。不过,如果我们能够获取开发/测试数据分布上的人类误差和训练误差,把上表填满,我们就能获取更多的启发。比如上表中,如果我们发现在开发/测试数据上人类的表现也是6%,这就说明开发/测试数据对于人类来说比较难,但是对模型来说比较简单。

完成多个任务

迁移学习

深度学习的一大强大之处,就是一个深度学习模型在某任务中学习到的知识,能够在另一项任务中使用。比如在计算机视觉中,目标检测等更难的任务会把图像分类任务的模型作为其模型组成的一部分。这种技术叫做迁移学习

假如我们有一个通用图像识别的数据集和一个医学图像识别数据集,我们可以先训好一个通用的图像识别模型,再对模型做一些调整,换医学图像数据上去再训练出一个医学图像识别模型。

具体来说,以上图中展示的情况为例,我们可以在训练完通用图像识别模型后,删掉最后一个输出层,初始化一个符合医学图像识别任务要求的输出层。之后,我们使用医学图像来训练。在这个过程中,如果新数据较少,我们既可以只训练最后的输出层,而保持其他层参数不变;如果新数据够多,我们可以让所有参数都参与训练。

这里还要介绍两个重要的深度学习名词。如果换新数据后要训练所有参数,则换数据前的训练过程称为预训练(pre-training) ,换数据后的训练过程称为微调(fine-tuning)

在上面的例子中,我们只是删掉了一个输出层,加了一个输出层。实际上,删哪些层换哪些层都没有一定的标准。如果任务变得更难了,我们可以删一个输出层,再加几个隐藏层和一个输出层。

迁移学习最常见的场合,是我们想完成训练数据较少的B任务,却在相似的A任务中有大量的训练数据。这时,我们就可以先学A任务,再迁移到B任务上。如果A、B任务的数据量差不多,那迁移学习就没什么意义了,因为同样是一份数据,对于B任务来说,一份B任务的数据肯定比一份A任务的数据要有用得多。

另外,迁移学习之所以能有效,是因为神经网络的浅层总能学到一些和任务无关,而之和数据相关的知识。因此,A任务和B任务要有一样的输入,且A任务的浅层特征能够帮助到任务B。

多任务学习

在刚刚学的迁移学习中,模型会先学任务A,再学任务B。而在另一个面向多个任务的学习方法中,模型可以并行地学习多个任务。这种学习方法叫做多任务学习

还是来先看一个例子。在开发无人驾驶车时,算法要分别识别出一张图片中是否有人行道、汽车、停止路牌、红绿灯……。识别每一种物体是否存在,都是一个二分类问题。使用多任务学习,我们可以让一个模型同时处理多个任务,即把模型的输出堆叠起来:

这里,一定要区分多个二分类问题和多分类问题。多分类中,一个物体只可能属于多个类别中的一种;而多个二分类问题中,图片可以被同时归为多个类别。

使用多任务学习时,除了输出数据格式需要改变,网络结构和损失函数也需要改变。多个二分类任务的网络结构和多分类的类似,都要在最后一层输出多个结果;而误差和多分类的不一样,不使用softmax,而是使用多个sigmoid求和(每个sigmoid对应一个二分类任务)。

此外,多个二分类任务和多分类任务还有一个不同。在执行多分类学习时,由于所有任务都用统一的数据,数据的标注可能有缺失。比如某几张图片可能没有标出红绿灯,另外几张图片又没有标出人行道。在多任务学习中,我们是允许数据中出现“模糊不清”的现象的,可以把没有标注的数据标成”?”。这样,碰到标注是”?”的数据时,我们就不对这一项进行损失函数的计算。

和迁移学习一样,多任务学习在使用上有一些要求。

首先,所有任务都必须受益于相同的浅层特征。这是显而易见的。

其次,每类任务的数据集都要差不多大。在迁移学习中,我们有比较大的数据集A和比较小的数据集B。而在迁移学习中,假如我们有100项任务,每种数据有1000条数据。对于每一项任务来说,其他99项任务的99000条数据就像数据集A一样,自己的1000条数据就像数据集B一样。

最后,经研究,只有当神经网络模型足够大时,使用多任务学习才至少不比分别学习每个任务差。

在实践中,迁移学习比多任务学习常见得多。

端到端深度学习

深度学习的另一大强大之处,就是端到端(end-to-end)学习。这项技术可以让搭建学习算法简单很多。让我们先看看端到端学习具体是指什么。

不使用深度学习的话,一项任务可能会被拆成多个子步骤。比如在NLP(自然语言处理)中,为了让电脑看懂人类的语言,传统方法会先提取语言中的词语,再根据语法组织起词语,最后再做进一步的处理。而在端到端学习中,深度学习可能一步就把任务完成了。比如说机器翻译这项NLP任务,用深度学习的话,输入是某语言的句子,输出就是另一个语言的句子,中间不需要有其他任何步骤。

相较于多步骤的方法,端到端学习的方法需要更多的数据。仅在数据足够的情况下,端到端学习才是有效的。下面,我们来看一个反例。

在人脸识别任务中,输入是一张图片,输出是图片中人脸的身份。这里有一个问题:识别人脸之前,算法需要先定位人脸的位置。如果使用端到端学习的话,学习算法要花很长时间才能学会找到人脸并识别人脸的身份。

相比之下,我们可以把这个人物拆成两个阶段:第一阶段,算法的输入是图片,输出是一个框,框出了人脸所在位置;第二阶段,输入是框里的人脸,输出是人脸的身份。学习算法可以轻松地完成这两个子问题,这种非端到端的方法反而更加通用。

总结一下,非端到端学习想要优于端到端学习,必须满足两个条件:每个子任务都比较简单;每个子任务的数据很多,而整个任务的数据很少。

那么,具体哪些情况下该用端到端学习,哪些情况下不用呢?我们来看看端到端学习的优缺点:

优点:

  • 让数据说话。相较于手工设计的某些步骤,端到端学习能够从海量数据中发现于更适合计算机理解的统计规律。
  • 减少手工设计的工作量,让设计者少花点精力。

缺点:

  • 可能需要大量的数据。
  • 排除了可能有用的手工设计的东西。比如人脸识别中,显然,找出人脸是一个绕不过去的子步骤。

归根结底,还是数据量决定了是否使用端到端学习。在复杂的任务中,要达成端到端需要非常非常多的数据,在不能够获取足够数据之前,还是使用多阶段的方法好;而对于简单的任务,可能要求的数据不多,直接用端到端学习就能很好地完成任务了。

总结

这周的知识点如下:

  • 错误分析
    • 用表格做错误分析
    • 统计错标数据
  • 数据不匹配
    • 何时使用数据分布不同的训练集和开发/测试集
    • 训练开发集
    • 如何诊断数据不匹配问题
  • 完成多个任务
    • 迁移学习的定义与常见做法
    • 预训练、微调
    • 多任务学习的定义
    • 多个二分类任务
    • 迁移学习与多任务学习的优劣、使用场景
  • 端到端深度学习
    • 认识端到端学习的例子
    • 何时使用端到端学习

和上周一样,这周的知识都是一些只需要了解的概念,没有什么很复杂的公式。大家可以较为轻松地看完这周的内容。

另外,这周也没有官方的编程作业。

经过了之前的学习,我们学会了许多改进深度学习模型的方法,比如:

  • 收集更多数据
  • 收集更多样化的数据
  • 延长训练时间
  • 用高级梯度下降算法
  • 缩小/扩大网络
  • 使用正则化
  • ……

这么多方法,如果只是一个一个试过去,开发效率就太低了。在未来的两周,我们会学习一些改进机器学习的策略。这些策略会给我们一些启发性的指导,让我们在改进模型时更明确下一步该做什么。

学习提示

这周课没有太多的新内容,主要是拓展了第二门课第一周有关偏差与方差分析的内容。学完了这周的课后,大家会进一步了解如何在一个全新的机器学习任务上设置目标,并通过误差分析等技术逐步靠近目标。

课堂笔记

正交化

如何从众多的改进方案中选择出优先级较高的呢?让我们先看看生活中一些其他事情的例子:

首先,是调整老式电视机的例子。老式电视机的画面不一定恰好能端端正正地填满屏幕,需要人为地调整画面的位置。一般这些电视机都有很多按钮,每个按钮各负责一项调整功能,比如调整上下位置、左右位置、缩放、旋转等。每个按钮之间的功能互不干扰。

另外,还有一个开汽车的例子。汽车最少有三种操作:转方向盘、加速、减速。只需要组合这三种操作,我们就能让汽车沿着某一路线跑起来。而如果汽车只有两个可以左右调整的按钮,一个按钮控制0.3倍的角度和-0.8倍的速度,另一个按钮控制2倍的角度和0.9倍的速度,那司机控制汽车时肯定会倍感吃力。

以上两个例子显示了正交化的好处。正交可以指数学里两条直线垂直,这里指的是两个调整方向互不干扰。通过调整正交的参数,我们可以把事物的“坐标分量”逐个调整到我们期待的“位置”。

类似地,在改进机器学习项目时,也可以使用正交化。

在机器学习项目中,大概有4个“坐标分量”需要调整:拟合训练集、拟合开发集、拟合测试集、提升实际应用中的表现。对于这每一项目标,我们都应该使用相互正交的策略去调整,比如:

  • 拟合训练集 - 用更大的网络
  • 拟合开发集 - 正则化
  • 拟合测试集 - 用更大的开发集
  • 提升实际应用中的表现 - 更换损失函数

值得一提的是,提前停止是一个即会影响训练误差,又会影响开发误差的方法。这个方法不满足正交化的要求,使用此方法时需要多多注意。

设置目标

单一指标

在分类任务中,一般有下面这两种评价指标:

  • 精确率(precision, 又称查准率):所有识别为猫的图片中,究竟有多少确实是猫?
  • 召回率(recall, 又称查全率):所有猫的图片中,有多少猫被正确识别了?

注意,我们之前代码实战中用的准确率(accuracy)和精确率(precision)不是一个指标。

现在,假设有两个模型,它们在开发集上的评估结果如下:

  • 模型1:精确率95%,召回率90%。
  • 模型2:精确率98%,召回率85%。

二者在精确率和召回率上各有优劣,该怎么从中选一个更好的模型出来呢?

设置目标的一个原则是:只使用单一实数作为评价标准。因此,我们要想办法用一个指标把这两个指标都考虑进来。比如使用F1-score,它的公式如下:

再看一个例子。假如我们开发好了几个算法,我们要用来自不同国家的数据去测试它们。不同算法在不同国家的数据上表现较好。为了快速选取一个最好的算法,我们可以去计算每个算法的表现平均值。

有了单一评价标准,我们就可以快速比较各个模型在开发集上的表现,并选择一个更好的模型。这样,我们开发的迭代速度也变快了。

满足指标与优化指标

在有多个评价指标时,不是总能挑选出一个最恰当的综合指标的。比如评价某算法时既要考虑到准确率,又要考虑到运行时间。用一个综合指标来组合它们显然不太现实。这时,我们可以把指标分成满足指标优化指标

比如说,我们有这样几个算法:

分类器 准确率 运行时间
A 90% 80ms
B 92% 95ms
C 95% 1500ms

算法C是挺好的,但是它相较A,B实在跑的太慢了。因此,我们可以设置以下的评价标准:

满足运行时间≤100ms的前提下,最大化准确率。

这个标准既保证了运行时间不会太长,又能选出准确率较高的算法。按照这个标准,B应该是最优的分类器。

在这个例子中,准确率就是优化指标,运行时间就是满足指标。

这种新的选取指标的方法应该和之前提到的单一指标原则结合起来。准确来说,应该只有一个优化指标,外加若干个满足指标。

训练/开发/测试的分布

开发集和评价指标,共同决定了我们的优化目标。因此,我们应该谨慎地选择各数据集的数据分布,防止优化目标跑偏。

举个例子,假如我们收集了来自不同地区的数据,有亚洲、欧洲……。假如我们令亚洲的数据为开发集,欧洲的数据为测试集,我们就可能会训练出一个在开发集上表现优秀,却在测试集上表现糟糕的模型。正确的做法是,我们把来自不同地区的数据打乱,把数据随机分成开发集和测试集。

还有一个改编自真实故事的例子。一个团队想开发根据某人的邮政编码预测他同意贷款的概率的算法。他们以中等收入地区的邮政编码为开发集,却以低收入地区的邮政编码为测试集。显然,在这两个地区的人同意贷款的概率会差很多。最后,这个团队花了3个月优化了算法在开发集上的表现,却发现模型在测试集上表现奇差,不得以推倒重来。

也就是说,我们应该让训练集和测试集能够反映我们将来实际应用时的数据,并且训练集和测试集都得来自同一个分布。设置开发集和评估指标,就像立了一个靶子一样。训练,就是让射出的箭更靠靶心。而测试集,应该反映我们期望箭射到的位置。我们既要知道箭应该射在哪里,还要把靶子摆对。

开发集和测试集的大小

这些知识在第二周已经学过了,这里再强调一次。

数据量小的时候(比如说数量级在万以下),我们可以按6:2:2的比例划分训练/开发/测试集。但数据量大的时候,就不用考虑比例了,按固定大小选择差不多大小的开发集和测试集即可。

那么,测试集要多大才够呢?从统计学的眼光来看,把测试集当成实际应用数据中的一个采样结果的话,我们应该保证测试集有很高的置信度能反映模型在实际应用中的综合表现。当然,对于简单的数据分布,我们可以用统计学知识严谨地算出置信度。而对于人工智能任务中用到的海量数据,数学工具就难以派上用场了。我们只能根据经验选择一个足够大的测试集。比如有百万级数据的话,一万个测试样本就够了。

何时更换开发/测试集与评价指标

在算法投入应用后,我们可能会发现新的评价角度。比如对于小猫分类模型,我们本来只期望它能正确识别小猫。可是,随着使用应用的人变多,我们发现有的用户会上传色情图片。这时,我们不仅希望模型能只找出小猫,还要能过滤掉色情图片。

这样,我们就引入了一个新的评价指标。这样,之前辨认小猫能力强的模型,可能会在辨认色情图片上较差。

为了考虑这个新的评价指标,我们可以修改误差函数,用更高的权重加大色情图片分类错误的惩罚。

总结来说,当我们发现使用当前指标得出来的最优模型,与考虑到某些新因素后得到的最优模型不同时,我们就应该更换开发/测试集与评价指标了。

与人类级表现比较

为什么是人类级表现

我们经常能看到AI与人类比较的新闻:什么AlphaGo在围棋上战胜人类了,什么在ImageNet上AI的分类准确率超过了人类啊,等等。除了博眼球的新闻外,业内同样也会时常将机器学习模型和人类比较。这是为什么呢?

在许多任务中,人类的表现都非常出色。当AI超过了人类后,往往也达到了这类问题的最优精度。在机器学习模型超过人类前,与人类比较有以下好处:

  1. 获取人类标注的数据。
  2. 从手动误差分析中获得启发:为什么人就能做对?
  3. 更好地分析偏差与方差。

其中,第1条是显然的,第2条会在下周介绍。接下来,我们看看第3条是怎么回事。

可规避偏差

这个知识之前也学过了一点。

如果一个模型的训练误差是8%,开发误差是10%,我们不一定说模型就存在这个偏差问题。有可能模型在训练集上已经几乎达到了最优的表现;

在判断一件事时,有可能因为信息的缺乏,最优的准确率也达不到100%,总会存在一些误差。这样的最小的误差叫做贝叶斯误差。人类的表现,通常可以用作贝叶斯误差的一个估计。

在刚才那个例子中,如果人类误差是1%,那么模型的训练误差还有7%的提升空间;而如果人类误差是7.5%,那说不定模型的训练误差只有0.5%的提升空间了。对于前者,我们应该关注偏差;关于后者,我们应该关注方差。这里讲到的7%, 0.5%的提升空间,可以称作可规避偏差

理解人类级表现

假如让人类来完成医学图片分类任务,人们得到了以下的分类误差:

从一个普通人,到一群有经验的医生,误差逐渐降低。那么,哪个误差算是人类级表现呢?

回顾上一节的内容,人类误差是贝叶斯误差的一个估计。因此,人类最优的表现,才应该被视作是人类误差。

当然,获取人类级表现的目的还是为了做偏差和方差分析。如果当前的训练误差是5%,那不管人类误差是1%,0.7%,还是5%,都差不多。而如果训练误差到了1%,甚至更低,那就要仔细地获取人类误差了。

提升模型表现

最后,再一次回顾一下如何减少偏差和方差。

机器学习有两大假设:模型能够很好地拟合训练集、模型能够泛化到开发/测试集上。它们分别对应偏差问题和方差问题。

训练误差和人类级表现之间的差是可规避偏差,开发集和训练集之间的差是方差。

训练更大的模型、训练更久/用更好的优化算法能够解决偏差问题。

使用更多数据、正则化能解决方差问题。

用更好的架构、超参数能同时解决这两个问题。

总结

这节课涉及的新知识很少,大家就权当是复习了一下之前的知识。这节课大概学了这些东西:

  • 正交化
  • 目标
    • 单一指标
    • 满足指标与优化指标
  • 开发集与测试集
    • 分布
    • 大小
  • 人类级表现
    • 贝叶斯误差
    • 可规避偏差
    • 提升模型表现的思路


只有保研生参加的说明会结束后,大家都围着辅导员,焦急而欣喜地确认着自己的前程。待人群散开后,我走到辅导员身边,询问放弃保研的流程。

“只要写一张确认放弃保研的保证书即可。”

拿出先前准备好的纸笔,我流畅地写了几行字,提交了本专业唯一一份保研放弃书。


2022年6月,没有季节之分的新加坡,却到了毒蚊肆虐的高峰期。我不幸感染病毒,前往南洋理工大学的校医院就诊。

诊断结束后,医生关切地说道:“你这几天就不要去听课了。我给你开假条。”

我笑道:“我是员工,不是学生。”

是啊,不论穿着、相貌、言行多像一名学生,现在的我,确确实实是一名员工。

离开诊所,望了望晴朗的天空,我忽然意识到,夏天来了。

如果是在国内的话,已经是夏天了吧?

应该是这样没错。去年的这个时候,学校里可热了。

全体学生聚在操场上的那一天,阳光正盛……


去年,我还是学生。

糊里糊涂地完成了毕业设计,通过了答辩,时间已经悄悄来到了六月。

这一天,太阳不遗余力地展示起了夏天的风采。火辣辣的阳光直射在北京理工大学中关村校区的操场上,我穿着严实的学士服,感到异常闷热。我一会儿调整着学士帽的角度以遮挡阳光,一会儿又摘下帽子当扇子扇风。

不一会儿,主持人宣布了毕业典礼的开始。在酷热的今天,哪怕是一向讨厌集会的我,也静了下来,默默地听着演讲。

主持人开始念起各专业毕业学生的名单了。理论上,名单是包含了每一位同学的名字的。可为了节约时间,主持人念完前几位学生的名字后,就会以“等人”来略过后面的名字。

一个个陌生的名字,就像一声声倒计时。我深深地感受到了毕业离校的临近。

有的人保研成功,已经去实验室待了几个月。

有的人面试成功,正在做正式入职的准备。

有的人考研成功,和未来的导师刚打完招呼。

可我呢?

我该去哪呢?

造成现在的局面,真的都是我自己的错吗?


不,我很早就想好自己的出路了。

早在大一,我就做好了出国留学的打算。

托福与GRE,硕士与博士。这些信息都我来说就如常识一般。

“先尝试科研,适合就读博士,不适合就读硕士。”

这是在综合了无数份信息后,得到的平均答案。

“大三开始在做科研,暑假去参加暑研。托福考试只有两年的有效期,也只能大三之后考。”

这也是平均而言的结论。

既然大家这么说,我也就这么做吧。

在快乐的算法竞赛中,我度过了大学的前两年。


大三到了,该做留学的准备了。

刚从竞赛暑期集训中缓过来的我,错过了,或者说压根就没注意过某个学校官方的大三暑研项目。“错过就错过吧,反正大家的暑研都是自己找的。到寒假了再找吧。”我勉强安慰着迷茫的自己。

按照计划,我去找了本校的老师做科研,提前看一看自己是否适合做研究。老师本来说让一个博士生带我做点项目,后来渐渐就没了音讯。大三课业繁重,又有最后半年竞赛要打,我也无暇顾及科研的事情了。

熬过上半学期,在留学上毫无进展的我,开始回家过寒假。我计划一边套磁(方言,意为“套近乎”,特指在申请出国留学时,提前给导师发邮件推荐自己)暑研,一边开始语言考试的准备,希望能在下半学期考完语言考试。

突如其来的一场疫情,打乱了我的计划。

当然,之前那个官方暑研项目也泡汤了。我只能以此来安慰自己。

为了让自己看上去在做一些什么,每天一个人在楼下上完网课后,我总会去套磁一个教授。

之所以每天只向一个教授发邮件,是因为我害怕发邮件这件事。

点开学校主页,找到教授的研究方向,在一堆陌生的名词中拎出一两个,组织成一封“我对你的研究感兴趣,请让我参与暑研”的邮件。这一过程对我来说,是一件很惶恐而绝望的事情。

每看完一个教授的简历后,我就隐隐感到自己的背景是绝不可能申请上他的暑研的。可是,正如坠落山崖的人总想抓住什么一样,我还是不得不把邮件发出去。每发出一封邮件,我就像了了一桩大事一般,如释重负。可是,每发出一封邮件,我又能意识到,又少了一个可以套磁的教授,无教授可找的绝望又离我近了一分。

就这样,为了消化套磁每天带来的压力,我只敢一天发一封邮件。

每天向不同的人告白,告白前就已经意识到了失败。可是,还是要为明天的告白做准备。大概就是这样痛苦的感觉吧。

虽然我还没有可以自由选择方向的资格,但我只想做图形学的研究。可是,我之前几乎没有任何相关科研经历,也没有任何人脉。纯粹做图形学的教授也越来越少。想找到暑研的难度是极大的。

我的心情很矛盾。一方面,我为做图形学的教授很少,没有套磁目标而担忧;另一方面,我又为做图形学的教授很少,可以过滤掉一批做其他方向的教授以逃避发邮件而感到释怀。或许,我所谓“热爱图形学”只是一块遮羞布而已。我害怕前途未卜的未来,我害怕在黑暗中迷失,但我又害怕迈出脚步。我选择图形学,或许只是图形学的教授很少,套磁失败起来很快而已。套磁完所有只做图形学的教授,失败了,我就可以以“我已经努力过了”来安慰自己。、

事实也确实如我所预料得一样,只有一个教授回了我邮件——一封找了一个温柔而拙劣的理由把我拒绝的邮件。我再也不用去套磁了,再也不用忍受发邮件的煎熬了。可是,我所担心的没有去处的未来,正在向我一步一步逼近。


大三下学期,我以“优等生”的姿态活着。

即使是网课,我也认真听着老师的讲解,认真完成着大作业。课余时间,我还继续在本校老师那做一点“科研”。

但是,我在留学上没有任何进展。

以“大三的作业太多”、“回学校后一切就能好起来”为借口,我暂时忘掉了留学这件事,舒服地过了几个月。

2020年年中,特朗普的一纸10043总统令,禁止某些中国高校的学生去美国留学,粉碎了无数学子的留学梦。历史的尘埃,恰恰就砸在了我们学校上。

听到这个消息,我的第一反应不是愤慨,不是焦虑,而是释怀:这下好了,大家都去不了美国了。

虽然我之前一直只打算申请美国的学校,但这个令我规划彻底失效的消息却使我获得了某种程度上的解脱。

是真的解脱了吗?还只是受到巨大打击之后的应激反应呢?

我不知道。

我只知道我必须要做一点什么,一定要迈出脚步。

我不想被无路可走的黑暗吞没。

有人说,这个留学禁令只会持续一年,明年的留学生肯定不受影响。我根本来不及仔细思考,立刻把这个判断奉为真理,继续之前的留学准备。


我一直都在做一点什么。

没有暑研,我就在学期结束后立刻返校,捣鼓我那怎么都没有成果的“科研”。

正式开学后,我立刻开启了托福的准备。

我一直都在做一点什么。

但我真的什么都不想做。

但凡做起和留学相关的事,我就感到无比煎熬。开始做了一会儿后,巨大的负担就压在了我的心上。没办法,我必须要逃避。我的炉石传说酒馆战棋打到了一万多分。

但我还是觉得该做一些什么。

我勉强考出了过线的GRE,快要过线的托福。

我参与了本校教授和国外教授的合作科研项目。两位美国老教授高风亮节,视如己出,言传身教,令我彻底下定决心去读博士。他们虽然颇有名誉,但和我不是一个专业,在留学上给不了我功利性的帮助。即使如此,我依然非常感激他们。

我急匆匆地做好了材料准备,提交了数个学校的博士申请。

12月中旬,我提交完了所有美国学校的申请。

一切都结束了。

我不用再做一些什么了吧?


只申请美国,只申请图形学,只申请博士。

我恐怕根本不是奔着成功留学去的,只是想做什么就做了什么而已。所谓“眼高手低”,大抵如此吧。

我是真的眼高手低吗?

聪明绝顶的我怎么可能没有对自己的一个客观认识。我知道,申请成功的概率微乎其微;我知道,六月之后即迎来“失学”的未来;我知道,我害怕失败,害怕无路可走的绝望。

但是,我更清楚我想要什么,不想要什么。

成功,不是美国顶尖学校的博士录取通知书,不是4.0的绩点,不是110分以上的托福分数,不是330分以上的GRE分数,不是光鲜亮丽的获奖记录,不是琳琅满目的论文发表记录。

成功,不是奖学金获得记录,不是年级第一的成绩,不是饱满的社会工作经历,不是“努力”、“感人”的苦学经历。当然,也不是我唯一能展示出来的,ACM金牌的获得记录。

成功,不是金钱,不是地位,不是权力,不是名誉,不是异性缘,不是房子,不是车子,不是你在哪国生活,不是你的照片多好看,不是你展示出来自己的生活过得有多好。

成功,是:我觉得成功,就是成功。

我觉得,只有做自己喜欢的事情,在自己喜欢的领域做出了令自己满意的成就,才叫成功。

打了三年左右的竞赛,大奖我拿的确实不多。但是,在这段时间里,我过得很开心。我触摸到了灵魂的兴奋点,初次体会到了人生的意义。

我害怕。

我害怕未来。

我害怕上不了学的未来。

我害怕申请失败上不了学的未来。

我害怕因为套磁不够积极导致申请失败上不了学的未来。

我害怕因为方向选得不够多套磁不够积极导致申请失败上不了学的未来。

因此,

我放弃。

我放弃思考。

我放弃人生规划的思考。

我放弃留学相关人生规划的思考。

我放弃寻找更合适的国内研究项目人生规划的思考。

我放弃套磁更多方向的教授寻找更合适的国内研究项目人生规划的思考。

但是,

我坚持。

我坚持底线。

我坚持人生价值的底线。

我坚持不随留学结果变动的人生价值的底线。

我坚持不肯妥协不随留学结果变动的人生价值的底线。

我坚持不肯妥协不随留学结果变动只为自己开心的人生价值的底线。

最终,我任性而顽固地在焦虑中失败了。不过,我也很庆幸,不管我的感受有多么糟糕,我在潜意识里依旧坚持了自己的底线。我没有为了留学而留学,也丝毫没有怀疑过自己对人生目标的判断。


后来,我依然焦急地寻找着出路。

我知道自己为逃避选择而产生的任性是很不合理的。赶在截止日期之前,我去尝试申请了其他国家的学校,尝试申请了可以转成博士的研究型硕士。结果,时间已晚,剩余的机会并不多,我也没有申请成功。

我已经在积累压力和释放压力中循环多次了。写套磁信时积累压力,发邮件时释放压力;申请学校时积累压力,申请季结束后释放压力;等结果时积累压力,收到拒信时释放压力。这就像一个溺水的人,反复挣扎出水面,难得呼吸到一两口新鲜空气一样。那么,收到最后一封拒信时,就是我最后一次能够离开水面了。

但是,我依然没有放弃“生的希望”。或许在很早之前,我就已经在心里默认自己会踏上这条退路了。

这条退路就是gap,去实验室先当科研助理,积累背景,再去申请博士。

gap是一个从国外传来的词,表示毕业后不去上学,而是去玩个一两年。用中文来说的话,gap year可以翻译成“间隔年”。到了国内留学圈,gap的意思就变了。毕业之后,不管你是不是在享受没有学业的人生,只要你没有上学,就可以称为gap。当科研助理,是一种最常见的gap方式。

从大一就开始看留学经验分享的我,很早就知道了gap的存在。通过分析他人的gap经验,我也欣然接受了gap,做好了心理准备。或许我在留学季的种种挣扎,不过是自我欺骗而已。我内心早就放弃了本科直接申请博士。由于有这个底牌的存在,我可以索性破罐子破摔,只去追求小概率的自己想要的结果。

虽说是早就做好了心理准备,但被压力挤得喘不过气的我,还是慢慢吞吞而消极地进行着gap的计划。我本来做好了去一家公司的准备,就没有去找第二个选择了。可是,毕业前我想了解入职事项时,却发现我莫名其妙地被鸽了。

毕业典礼即将到来,我选择享受最后一刻的本科时光,搁置了gap的事。

本科毕业后,作为无业游民的我回到了家里,立刻开始了科研助理的套磁。

和之前的暑研套磁一样,申请科研助理也要用同样的方式发邮件申请岗位。一想起暑研,整个留学过程给我带来的压力的总和就扑面而来。同样,我的心理承受能力只允许我一天只发一两封邮件。

待在家里天天吃干饭,我肯定会被无尽的压力给冲垮。恰巧同学邀请我去毕业旅行,我欣然答应。不知怎地,我就是有一种能在旅游中申请成功的自信。

由于美国的学校都去不了了,现在我只能从其他国家入手。这次,我不再头铁了,从对ACM竞赛认可度最高的华人圈开始申请。同时,由于做图形学的人太少了,我决定扩大范围,也申请计算机视觉方向的研究。计算机视觉我也不讨厌,我会让自己尽快喜欢上这个领域,并且尽可能选择和图形学相关的细分方向。

我认真套了几个香港的教授,杳无音讯。我又看到南洋理工大学在招聘平台上正式招募科研助理,就顺手投了一份简历。正当我为没收到任何回信,准备进一步扩大方向的选择范围时,我申请得最不认真的南洋理工大学竟然向我发出了面试邀请。

说是面试,但这毕竟不是庄重的博士申请。能给科研助理的申请发面试机会,基本上就是决定要你了。我本来还准备了英文ppt和英文演讲腹稿,谁知面试开始后,老师亲切地对我说可以说中文。谈起选择我的理由时,老师说,像我这样有扎实的底层编程基础的人不多,而且我的博客写得很好。在轻松的氛围中,我们聊了聊我过去的经历,敲定了科研助理一事。由于疫情,新加坡签证管得严,我要等半年才能拿到签证。老师帮我先安排了一个和他的实验室有合作的国内工作岗位,就当是为之后的学习打基础。

没想到,这一次,如我所愿地,我在旅行中完成了套磁、科研助理面试、国内工作岗位面试。旅行的时间不短,在享受完旅行后没在家躺几天,我就得动身前往上海办入职了。


七月底,我去上海人工智能实验室的OpenMMLab以全职员工的身份“实习”。也就是说,工资按正式员工的发,但是和实习生一样不待很长时间。大概六个月后,签证就会办好。

总算,我也是从学校迈向社会了。很幸运,OpenMMLab是混沌社会中的一块净土。OpenMMLab主要做的是开源项目,不以业务为导向,没有什么KPI的压力。同时,由于大组刚刚成立不久,同事的素质都很高。全职员工大多是名校硕士,实习生中有名校本科生,也有在读博士的科研大佬。

站在徐汇西岸智塔的高层,俯视着蓝天下的黄浦江,我有一种说不出来的畅快。这样开阔的风景,是矮小的校园里所见不到的。从这里望出去,哪怕是上海交通大学,也不过我眼睛里的一点而已。

公司里见到的,都是年轻的面孔。于其说是同事,倒是更像大学里的同学。可是,多数同事都已经工作多年,早已褪去了学生的稚嫩。从他们口中,听到的更多是人情冷暖。房子、车子、伴侣……尽是些我插不上嘴的话题。

作为工资可观,又随时准备走人的单身程序员,我的日子倒是逍遥得很。可是,同事们比我有更多的可待之物。即使公司的工作环境比其他许多地方都要舒适,他们依然觉得上班养家是一件很不容易的事情。从他们身上,我看到了自己可能的一个未来:我就这样一辈子生活在上海,结婚生子,悠闲度日……

然而,现在安逸的生活让我忘记了本科申请时的所有烦恼。我以前所未有的高效率生活着,对未来的人生也有了更多的期冀。既然看到了校园内看不到的风景,那就要树立本科时想不到的理想。


说是全国最大的开源算法体系,也不过如此嘛。

不然,为什么重构代码库的事情,会让工作时间不过四个月的我来承担呢?

刚到公司时,我确实是懵懵懂懂的。我配开发环境配了一两天,给我开通企业微信又花了一周多,好不容易才安顿下来。

第一次小组会,我是以一个听众的身份参加的。ONNX Runtime、TensorRT、ncnn……这些犹如外星语的名词一个一个蹦出,令唯一一个听众感到战战兢兢:“这么多复杂的技术,我能学得过来吗?”

我们小组负责模型部署代码库的开发。学了一段时间的相关知识后,我发现,模型部署,可是光鲜的“算法”项目中最脏最累的活。对内,我们要对接数个计算机视觉的开源库;对外,我们又要使用数个运行深度学习模型的推理引擎。其他各个代码库之间不一致的地方,就要靠我们来硬生生地焊接起来。

这么琐碎的工作,自然也容易出现纰漏。正在学习我们的代码库时,我发现了一个bug。正好,我决定修复这个bug,作为我对我们组的第一份贡献。

提交代码,必须要使用到代码管理工具。本科时,我只会用傻瓜式的图形界面来使用Git这项代码管理工具。我们是做开源项目的,自然要把代码放到基于Git技术的GitHub开源代码平台上。由于经验不足,我只能在实践中慢慢学习Git的用法。

和组里的同时讨教过后,我修完了bug,并在自己电脑上完成了代码管理。之后,只剩下把代码提交到小组的代码平台,并把我写的那部分代码合入到整个代码库里了。我接下来的操作会改动代码库,一旦出了纰漏,肯定会引起很严重的后果。因此,我小心翼翼地进行着提交代码的操作。

提交完成后,代码库网站上突然出现了一个大大的红叉。这可把我吓坏了。我连忙向同事求救,一面拜托他们快点撤销掉我的操作,一面询问着正确的操作方法。还好,我错误的操作没有什么破坏性。原来,在使用Git和GitHub时,我不能直接向主代码库提交代码,而是应该先向自己克隆出的代码库提交代码。只要按照正确的步骤,重新操作一次就好了。

有惊无险地,我的第一份代码总算合入了整个项目中。虽然代码上的改动只有四五行,但我还是很骄傲地在下次组会上汇报了我的成就。小组领导也在会议记录上欣然记录下了我的这项产出,与其他人涉及上百行代码修改的成果一起。

提交完第一次代码之后,原先像城堡一样复杂的开源代码,在我眼里就成了一排排的破房子。我们的工作,不过是立几根杆子撑住快要倒塌的房子,又去旁边的土地上新建几座房子而已。

从提交几行代码修复小bug,到对接一个视觉算法库,我的贡献度逐渐向其他同事靠拢。几个月后,把略有难度的重构任务交给我,也算是自然而然的事。

为了完成重构,不阻碍他人的工作进度,我高压工作了几天。不过,我倒是不怎么感到疲惫——

我们的代码库要开源了。


2021年的平安夜,上海下着小雨。街头的树上挂着灯饰,点亮了黑夜,也点亮了路旁的积水。街道仿佛笼罩在一片白雪之中,就和人们印象中的圣诞节一样。

到处都是圣诞节的氛围。我从公司楼下的商场走出,一路上看到了不少情侣。恰逢本周最后一个工作日,大家都早早地下班过节。不知怎地,在这种氛围的感染下,我望着天空,感到一丝惆怅。

大概是因为,下周一,我们的项目就要开源了吧。

虽说我们的项目叫做“开源项目”,但是在代码功能尚未齐全的早期,项目是在私有账号下闭源开发的。在基本功能差不多完备了后,大组领导会择一良辰吉日,隆重向世人宣布开源。最后一次开源评审的通过、宣传视频终稿的提交、暂停开发工作后无聊而紧张的查缺补漏……一切都预示着项目开源的到来。

周一的晚上,一切准备就绪。小组的各位都聚在同一台电脑前。

这些代码是属于谁的呢?

作为员工,这些代码应该是属于公司的吧。

作为开源项目,这些代码又应该属于整个开源社区的吧。

但是,此时此刻,这些代码就是只属于我们的作品。

按下确认开源代码库的按钮后,大家纷纷鼓起了掌。

随后,大家不约而同地转发了我们代码库开源的宣传文章。

我想,现在,其他几位同事的感受,应该和我是一样的。

过了几天,仗着OpenMMLab的名气,我们的代码库登上了GitHub Trending榜第一。

之后,我们的身份从纯粹的开发者,变成了时而回答社区问题的客服人员。

再之后,就过年了。

过年回来,没待两周,我就收到了新加坡签证通过的消息。我很快办好了离职。

虽说是离职了,但我也没能立刻就离开上海。我心安理得地放了一周的假,像相恋多年和平分手却又一时不习惯分离的情侣一样,天天在公司里吃了一顿又一顿的散伙饭。

在香港转机时,我们需要在机场就地过夜。

在明亮的大厅里,我睡不着,又想起了同样明亮的那个夜晚。

原来,令我惆怅的,是一月份的到来。从一月往前数六个月,就是由热转凉的七月啊。


到了新加坡后,我很快就熟悉了学校里的生活。

去食堂点菜,刚掏出员工卡时,总有店家会向我确认道:“付款方式是学生卡吧?”我也总是点头默认。

被别人当成学生时,我总会很开心。 或许,我一直向往“学生”般天真烂漫、无拘无束的生活吧。

很幸运,现在,我正享受着这种生活。

我当了十六年学生,一直对众人口中所谓的“学习”嗤之以鼻。没想到,我却在不是学生的今天,体会到了真正的学习:没有家长,没有作业,没有考试,我可以出于热爱,为了自己而学习。在导师的计划下,除了完成实验室的项目外,我的主要任务就是从头认真学一遍深度学习,为以后的科研打下基础。

做着喜欢的事情,朝着理想一步一步迈进,这是我梦寐以求的生活。

没有学业的约束,没有最晚起床时间,能整天都抱着电脑。

其实,我现在有的条件,去年大四时也有。

这一年来,究竟是哪改变了呢?

我想,应该是心境吧。


去年,我一直带着“前途未卜”这项异常状态。

虽说是一直有这么个东西压在心上,可从客观上来看,我大四一年的生活都没受到任何影响。该考试考试,该写论文写论文,该毕业毕业。一切都正常地进行着。

可是,毕业,对没有去处的我来说就像是世界末日一样。仿佛一毕业,一盆水就浇到了我人生的水彩画上,我拥有的一切都将褪色,消逝。我根本不敢考虑之后的事情。

我的感受,完全是自己创造出来的。我惧怕未来,所以给自己创造了一个险恶的心理环境。虽然我想挣扎着逃出,可每一项努力的失败,又在我心中下起了一阵阵暴雨。我在自己给自己设下的绝境中,无法自拔。

我口口声声说着自己不忌惮世俗的眼光,可到头来还是难以免俗。分数、论文数量、录取学校,这些东西都成我心中挥之不去的阴影。

我所谓做好了gap的准备,不过是自欺欺人。连现在的东西都不肯割舍,连未来的方向都不敢主动去寻找。我只是一直在被外界推着前进,而难以自己迈出脚步。

阻碍我的,是我自己的心境。

可是,当时的我真的就有能力去改变自己的心境吗?

做不到的。

当时的我,只能看到那些东西。

从学校到公司不过一个多月,我的心情就大有转变。显然,并不是我聪明了多少,或是坚强了多少。一切,都只是环境变了。

找到出路,不过是让我能够从泥潭中走出。而在半年的实习经验,则洗净了我身上的泥。

人的思考方式不可能在短期改变,能够快速改变的,只有身处的环境。环境的改变,有时更能让人产生思考、心境上的转变。

正是因为见到了从未见过的东西,我才能认识到之前的浅薄。如果当年在学校时,我能够多找一些有相同境遇的人交流,或是提前去社会里看看,又或是暂别学校好好清醒几天,说不定早就能够走出心理上的牢笼。

心境决定了感觉上的好恶,环境又很大程度上影响了心境。

面对心里的险境,一方面要看开一点,在更广的时间和空间上看待目前的处境;另一方面,不必去苛责自己,说不定换一个环境,一切都会好起来。

这世上所有与内心的苦难所斗争的人啊:

你们千万不要气馁。

人的一生,必然是伴随苦难的。小时候,有做不完的假期作业,父母老师的责骂,吓人的期末考试;长大了,有千军万马过独木桥的高考、考研,有毕业后逃不开的就业;再往后,还可能有破产、众叛亲离、疾病缠身。

苦难压得人喘不过气,让人想要逃避。

可是,逃避又有什么错呢?

遭遇苦难,必然是在追求自我超越的路途之中的。敢于去挑战困难,本来就不是一件容易的事。

那么,短暂的逃避,也不过是出于自我保护,为了让干涸的心灵多浸润几滴甘露而已。

真正的勇士,从来都不是一帆风顺的人。有拼搏,有苦难,有逃避,有自责,有前进。这样的人,才称得上是勇敢的。

我想,笼中之鸟,也梦想过展翅翱翔;井底之蛙,也畅想过圆形以外的世界。不论现状多么糟糕,不论视野多么受限,大家都不会放弃对美好的期盼。这时,不妨转换一下环境,调整自己的心境。说不定现在看来天都快塌下来的事情,在未来只是一桩笑谈。

未来,随着我能做到的越来越多,肯定会经历更大的挫折,面对更难的挑战。我也不能保证自己就不会再次陷入心情的低谷中。但是,无论何时,我都会坚持自己的追求。不论是从主观上改变对困难本身的看法,还是改变客观的环境让自己冷静下来,我会用种种手段来摆脱困境。因此,在未来,只会留下更多我战胜困难的事迹。


保研说明会是在大四开学不久后召开的。当时,我连语言考试都没有准备好。说明会一结束,我就回去练听力了。如果能让现在的我给当时的自己带一句话,我会说:

池中寄卧又何妨,风雨之巅尽是晴。

我的评论

我本来是打算取得了某些成就后,再认真总结留学的心得的。恰逢上个月CSDN办了一场征文活动(活动的质量烂得一塌糊涂),我就随手写了一篇人生感想。等我以后确实有成就了,再写一篇有关CS PhD留学的思路指南。

这篇文章的质量很一般。用词还需要多加考究,事情完全贴合实际而少了一些阅读上的趣味,并且很多文字我是以演讲者的视角写的,念起来通顺但不严格符合语法。文章分了几次写完,行文中有不连贯之处。说理时略显僵硬,明明有很多方面的感想,却只能勉强揉成一团表达出来。倒是叙事结构上稍有构想,略微超出了我的平均写作水平。

但是,这篇文章最重要的,是文章内容中传递出来的“我”的心理活动,以及文字写作中传递出来的我的心理活动。这些感受都是很真切的。我觉得这是本文最宝贵的地方。

顺带一提,我是不怎么读书的,文学积累严重不足。为了写本文最后的诗句,我还特意去查一下格律,确保平仄没有写错。这两句话质量如何,我现在评价不了。但还是一样,它们蕴含了我的志气。

写这篇文章,我的主要目的是吹牛,试图收割流量。另外,我也很想把我的经历分享给更多的人。一方面,我知道大部分人都会经历和我类似的境遇,都会体会到孤立无援的感觉,相信这篇文章能给人启发;另一方面,我认为世界上广泛流传的价值观全是错的,我必须去宣传一些能让大家变得更好的思考方式。

希望大家读后能有所收获。

GAN(生成对抗网络)是一种能够自动生成内容的神经网络模型。近年来,许多图像生成的研究都基于GAN。

以人脸生成任务为例,一类常见图像GAN的原理如下:模型先要学会辨别一张图像是不是人脸。之后,模型会把一个个高维实数向量表示的“身份证号”映射成一幅幅图像,并根据辨别人脸的知识学习如何让图像长得更像人脸。最终,学习结束后,每一个“身份证号”都会映射到一张逼真的人脸上。只需要给模型一个随机的高维向量,模型就会生成一张人脸。

但是,使用这类图像GAN时,我们不能对图像生成的过程加以干预。也就是说,我们不知道神经网络是怎么把一个个向量映射成一张张栩栩如生的人脸的,而只能将其视作一个黑盒。因此,这些GAN只能用来随机地画出几张漂亮的画,搞搞大新闻,难以产生更加实际的应用。

对此,英伟达提出了可控的图像生成网络StyleGAN,引发了无数研究者的关注。在这个模型生成完一幅图像后,我们可以对图片进行由粗至精共18种微调:同样以人脸生成为例,我们既可以调整性别、年龄这种更宏观的属性,也能调整肤色、发色这种更具体的属性。下图是论文中展示的结果:

最左侧一列是生成的图集A,最上方一行是生成的图集B。前三行、中间两行、最后一行分别是把图集B图像的高级、中级、低级属性混合进图集A图像得到的结果。

可以看出,混入高级属性,人脸的肤色得以保留,而五官、性别等特征被修改了;混入中级属性,人脸的性别、年龄得以保留,而脸部的轮廓被修改了;混入低级属性,人脸的样子几乎不变,而发色、肤色被修改了。StyleGAN确实能神奇地修改生成图像的各类属性。

那么,StyleGAN是如何用这18支“画笔”作画的呢?StyleGAN还有哪些出色的特性呢?我们能用StyleGAN开发出怎样的应用呢?在这篇文章里,让我们来认识一下StyleGAN的主要特点,并快速地用开源项目运行一下StyleGAN。

本文不会对StyleGAN的原理进行详细的解读。在后续文章中,我会系统地讲解StyleGAN的论文及开源实现的使用方法。

原理讲解

控制图像的风格

StyleGAN的名字里有一个”style”,该单词是“风格”的意思。这个单词在图像领域有一个特别的含义:一项神经网络风格迁移(Neural Style Transfer)的研究曾指出,图像可以看成是内容与风格的结合。即使是同一幅风景,不同艺术家也会画出不同风格的作品:

如果对神经网络风格迁移的经典论文感兴趣,欢迎阅读我之前写的解读文章

在那项研究中,人们发现,在由很多卷积层堆成的卷积神经网络中,越浅的层能表示越实际的风格,越深的层能表示越抽象的风格。受此启发,StyleGAN也使用了卷积神经网络来生成图像,并把“控制信号”放在了每个卷积层后。这样,只要调整“控制信号”,就能改变图像的风格了。StyleGAN的生成网络的部分结构如下:

在这个网络中,”Const 4x4x512”表示一个恒定的数据块,该数据块的分辨率是4x4。网络会通过一系列操作,把这个数据块逐渐放大成一幅1024x1024的图像。”AdaIN”是一种运算,该运算受到表示风格的“控制信号”A的影响。”Conv 3x3”是普通的3x3卷积层。”Upsample”表示图像上采样2倍。

把图像从4x4翻倍放大至1024x1024,要翻倍8次,一共涉及9个模块的运算(一个模块就是图中的一个灰框)。而每个模块里又有2个AdaIN,所以,一共有18个调整图像风格的“控制信号”。这就是为什么我们可以说AI在用18支画笔作画。

那么,如何像开头所展示得一样,混合不同人脸的风格呢?这就要详细介绍一下“控制信号”的由来了。前面也提过,通常图像GAN需要输入一个高维实数向量,模型会根据这个向量来生成图像。在StyleGAN中,“控制信号”就来自于这个高维向量。默认情况下,所有“控制信号”都来自同一个高维向量。而如果令某些层的“控制信号”来自于另一个高维向量,就能产生风格混合的效果。前面提到把图像B的低级、高级特征混入图像A,其实就是用图像B代替图像A在网络的浅层、深层的“控制信号”。

为什么调整不同层的风格能够对图像产生不同程度的改变呢?可以这样想象:浅层的数据分辨率较低,只能记录图像的年龄、姿态这种更宏观的信息;而随着数据的分辨率不断放大,深层的数据已经逐渐记录下了人脸的形状,只剩下肤色、发色这种更具体的信息可供更改了。

实际上,从开头展示出来的图片中能够看出,风格混合并不是真的混合了图像的绘画风格,而是混合了图像的各种属性。出于对之前「神经风格迁移」论文中“风格”一词的统一描述,StyleGAN的论文沿用了“风格”一词。

除此之外,StyleGAN还有哪些特性呢?让我们看下去。

随机调整图像细节

在让计算机生成图像时,除了要求图像足够像某种事物外,最好还要能够随机改变图像的细节。比如对于一幅人脸照片来说,如果几束头发的位置发生了偏移,我们还是会认为这是原来那张照片。因此,我们希望生成出来的人脸在头发上的细节可以随机一点。

在传统的图像生成方法中,研究者总是得构造出一些巧妙的参数,通过随机改变这些参数让图像在内容大致不变的前提下调整细节。构造这些参数的过程是非常困难的。而对于StyleGAN来说,它的结构特别适合插入能够修改图像细节的噪声,让我们看一看StyleGAN生成网络完整的结构图:

其实,在输入层或者卷积层后,还有一个与噪声B的加法操作。其中,B通常是从标准正态分布中随机生成的。对于同一个高维向量生成出的图像,改变噪声B会修改图像的细节:

如图所示,改变噪声会改变头发的细节。实际上,噪声几乎不会影响整幅图片的观感,而只会改变头发、胡须、衣领等小细节。

通过改变噪声,我们能够从一幅照片的多个版本中找出一个细节最好的。此外,通过连续改变噪声,我们能够让图像发生连续的变化。这一特性很适合制作简单的2D动画。

靠近平均图像

StyleGAN还有一个很好玩的应用:让生成的某张脸更加靠近大众脸。这一功能是怎么实现的呢?

对数字求平均值,得到的是所有数字的平均水平;对坐标求平均值,得到是一个平均位置。可是,该怎么对照片求平均值呢?如果只是把所有照片的像素值加到一起,再求平均值,肯定只会得到一幅乱糟糟的图像。而StyleGAN则提供了一种求平均图像的方法。

前面提过,GAN是靠一个高维向量表示的“身份证号”来生成图像的。StyleGAN通过一些映射操作,让高维向量的距离与生成出来的图片的相似度相关。也就是说,越近的向量,生成出来的图片就越像。因此,我们可以求出一堆向量的平均值,从而得到一幅平均图像。

有了平均图像,接下来就是如何让另一幅图像更靠近平均图像了。和前面一样的道理,相似的向量能生成相似的图像,那么两个向量的平均值,就能几乎均等地表示两幅图的特性。理想情况下,如果一个向量表示“白发萝莉”,另一个向量表示“黑发熟女”,那么它们的平均向量应该表示“银发少女”。当然,如果不使用平均值,而是使用其他和为1的加权方式,就能让中间的图像更靠近另外某幅图像了。这一操作叫做图像插值。

有了平均图像,有了图像插值方法,就可以一幅图像更靠近平均图像了。下面是几个插值示例图,其中$\psi$表示原图像的加权权重:

$\psi=1$表示随机生成的原图像,$\psi=0$就是平均图像,$\psi<0$表示图像往平均图像“相反”的方向移动得到的图像。

可以看出,随着人像不断靠近平均,甚至往反的方向移动,人像的整体内容都在平滑地改变。比较有趣的是,当人像反向后,人物的性别都反过来了。

从这个例子能看出,StyleGAN使用的输入向量隐含了语义上的信息。通过对输入向量进行简单的数学操作,就能让生成出来的图像朝有意义的方向改变。

总结与展望

通过认识StyleGAN的网络结构,我们了解了StyleGAN的两大输入:表示风格的高维向量与随机扰动图像的噪声。通过修改这些输入,图像会发生不同程度的改变。

基于这些基本操作,我们可以开发出许多图像编辑应用,比如风格混合、简单动画、图像插值、语义反向等。正是因为这些五花八门的图像编辑效果,许多研究者都尝试对StyleGAN的功能进行改进与拓展,发表了很多有趣的科研工作。

令一方面,由于GAN的生成内容取决于训练数据。如果我们用人脸以外的图片作为训练集,就可以让AI画出其他物体来,比如动漫头像、小猫、汽车、酒店房间。这样,就可以开发出人脸编辑之外的应用了。

如果你想直观地体会StyleGAN的效果,可以查看StyleGAN作者发布的视频(在外网)。

如果你想立刻跑一跑StyleGAN,别走开。在附录中,我会介绍如何利用开源项目快速运行StyleGAN。

这篇文章只是对StyleGAN非常粗浅的一个介绍。如果你想认真研究StyleGAN,欢迎阅读我之后发布的StyleGAN论文精读。

快速运行StyleGAN

OpenMMLabMMGeneration用PyTorch实现了StyleGAN并提供了模型权重文件。让我们看看该怎样快速运行StyleGAN。

我使用的MMGen版本是0.7.1

安装

访问官方文档,按照教程装好mmgen。

安装大致分以下几步:

  1. 装PyTorch。
  2. 装mmcv。
  3. 装mmgen。

下模型权重文件

这个网站里找到模型的下载链接。

模型名称中最后的数字表示生成图像的分辨率。按照需要,点击某个模型后面的”model”,下载权重文件。

权重文件下载好了后,推荐放到代码仓库的checkpoints目录下。

运行模型

在某目录下(比如代码仓库的work_dirs目录下)新建并编写Python文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from mmgen.apis import init_model, sample_unconditional_model
import mmcv
from torchvision import utils

config_file = "configs/styleganv1/styleganv1_ffhq_1024_g8_25Mimg.py"
checkpoint_file = "checkpoints/styleganv1_ffhq_1024_g8_25Mimg_20210407_161627-850a7234.pth"
device = "cuda:0"

model = init_model(config_file, checkpoint_file, device)
results = sample_unconditional_model(model, 16)
results = (results[:, [2, 1, 0]] + 1.) / 2.

# save images
mmcv.mkdir_or_exist('work_dirs/styleganv1')
utils.save_image(results, 'work_dirs/styleganv1/output.png', nrow=4)

稍微解释一下这段代码。init_model用于新建一个模型,该函数有三个参数:配置文件config_file、权重文件checkpoint_file、运行设备device。配置文件放在仓库的configs/styleganv1目录下,根据刚刚下载的权重文件,选择配套的配置文件,修改config_file即可。checkpoint_file要填写刚刚下载好的权重文件的路径。device是运行的设备,有GPU的话写cuda:0即可。

sample_unconditional_model用于随机生成一些向量,并用这些向量来生成图片。该函数第一个参数是模型,第二个参数是生成图像的数量。

图像生成完毕后,数据范围是(-1, 1),我们要把它转换成数据范围是(0, 1)的图像。同时,为了兼容输出图像的API,我们还要把颜色通道反向。

最后,调用创建文件夹和保存图片的API,把所有输出图片以网格形式保存到某个文件中。

执行这个Python脚本后,我们就能得到分布在4x4网格中的16幅人脸图像了。我得到的一个结果是:

注意!虽然大部分情况下生成器的表现都挺不错,但它偶尔会生成一些很吓人的图像(比如右上角那张史莱姆人)。大家看输出结果前一定要做好心理准备!


这段代码仅仅展示了StyleGAN生成图像的基本功能。在后续的论文解读文章中,我还会继续介绍如何利用mmgen实现StyleGAN的各种花式应用。

姊妹篇:https://zhouyifan.net/2022/06/27/DLS-note-7-2/。

安装PyTorch

前言

配编程环境考察的是利用搜索引擎的能力。在配环境时,应该多参考几篇文章。有英文阅读能力的应该去参考官方给的配置教程。出了问题把问题的出错信息放到搜索引擎上去查。一般多踩几次坑,多花点时间,环境总能配好。

本文只能给出一个大概率可行的指导,不能覆盖所有情况。如果在执行本文的安装步骤时出了问题,请灵活使用搜索引擎。

配置深度学习编程框架时,强烈推荐配置GPU版本。本文会介绍PyTorch GPU版本的配置。如果只想用CPU版本的话,跳过“CUDA与cuDNN”一节即可。

本文会同时介绍Windows和Linux下的安装方法。二者操作有区别的地方本文会特别强调,若没有强调则默认二者处理方法一致。

CUDA与cuDNN

CUDA是NVIDIA显卡的GPU编程语言。cuDNN是基于CUDA编写的GPU深度学习编程库。在使用深度学习编程框架时,我们一般都要装好CUDA和cuDNN。

这个安装步骤主要分三步:

  1. 装显卡驱动
  2. 装CUDA
  3. 装cuDNN

其中,显卡驱动一般不需要手动安装,尤其是在自带了NVIDIA显卡的Windows电脑上。

显卡驱动

nvidia-smi查看电脑的CUDA驱动最高支持版本。下图标出了命令运行成功后该信息所在位置:

如果命令能成功运行,记住这个信息。

如果这个命令失败了,就说明电脑需要重新安装显卡驱动。现在(2022年)CUDA的主流版本都是11.x,如果你发现驱动支持的最高版本偏低,也可以按照下面的步骤重新安装显卡驱动。

访问NVIDIA驱动官网:https://www.nvidia.cn/geforce/drivers/ 。在网站上,输入显卡型号和操作系统等信息,即可找到对应的驱动安装程序。

对于Windows,下载的是一个有GUI的安装器;对于Linux,下载的是一个shell脚本。如果你用的是Linux服务器,没有图形接口,可以先复制好下载链接,之后用wget下载脚本。

之后,运行安装器,按照指引即可完成驱动的安装。

注意,如果是带图形界面的Linux系统,可能要关闭图像界面再安装驱动。比如对于Ubuntu,一般要关闭nouveau再重启。请参考 https://zhuanlan.zhihu.com/p/59618999 等专门介绍Ubuntu显卡驱动安装的文章。

能够执行nvidia-smi后,执行该命令,找到驱动支持的最高CUDA版本。

CUDA

首先,我们要定一个CUDA安装版本。

CUDA安装版本的第一个限制是,该版本不能大于刚刚在nvidia-smi中获取的最高CUDA版本。

第二个限制是,PyTorch版本必须支持当前CUDA版本。在 https://pytorch.org/get-started/previous-versions/ 中,有许多安装命令。每条Linux和Windows的安装命令中,有一条cudatoolkit=x.x的参数。这个参数表示的是当前PyTorch版本一定支持的CUDA版本。当然,并不是其他版本就不支持,一般新CUDA版本会向旧版的兼容。为了保险,可以尽可能和安装命令中的CUDA版本对齐。

由于开发环境中可能会安装多个编程框架(TensorFlow,PyTorch),建议先安装一个比较常用、版本较高的CUDA,比如CUDA 11.1,11.2之类的。之后,让编程框架向CUDA版本妥协。

如果之后安装PyTorch后发现CUDA版本不对应,可以尝试升级PyTorch版本。如果PyTorch实在是支持不了当前的CUDA版本,最后再考虑降级当前的CUDA版本。

选好了CUDA版本后,去 https://developer.nvidia.com/cuda-toolkit-archive 上下载CUDA安装器。同样,Windows和Linux分别会得到GUI安装器和shell脚本。

装完CUDA后,再控制台上输入nvcc -Vnvcc是CUDA专用的编译器,-V用于查询版本。如果这个命令能够运行,就说明CUDA已经装好了。以下是nvcc -V的输出:

cuDNN

打开下载网站 https://developer.nvidia.com/rdp/cudnn-download (最新版本) 或 https://developer.nvidia.com/rdp/cudnn-archive (历史版本)。注册账号并登录。

根据CUDA版本,找到合适版本的cuDNN。https://docs.nvidia.com/deeplearning/cudnn/archives/index.html 这个网站列出了每个cuDNN版本支持的CUDA版本(Support Matrix)。一般来说,可以去找最新的cuDNN,看它是否兼容当前的CUDA版本。如果不行,再考虑降级cuDNN。一般来说,CUDA 11.x 的兼容性都很好。

选好了cuDNN版本后,去上面的下载网站上下载最新或某个历史版本的cuDNN。注意,应该下载一个压缩文件,而不应该下载一个可执行文件。比如对于所有的Linux系统,都应该下载”xxx for Linux x86_64 (Tar)”

装CUDA和cuDNN,主要的目的是把它们的动态库放进环境变量里,把头文件放到系统头文件目录变量里。因此,下一步,我们要把cuDNN的文件放到系统能够找到的地方。由于CUDA的库目录、包含目录都会在安装时自动设置好,一种简单的配置方法是把cuDNN的文件放到CUDA的对应目录里。

对于Windows,我们要找到CUDA的安装目录,比如C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.2。再找到刚刚cuDNN解压后的目录,比如D:\Download\cudnn-11.1-windows-x64-v8.0.4.30\cuda。把cuDNN目录下bin、include、lib里的文件分别复制到CUDA目录的对应文件夹中。

对于Linux,CUDA的安装目录一般是/usr/local/cuda。再找到cuDNN的解压目录,比如~/Downloads/cudnn-linux-x86_64-8.4.0.27_cuda11.6-archive。切换到cuDNN的根目录下,输入类似下面的命令:

1
2
3
4
sudo cp include/* /usr/local/cuda/include
sudo cp lib/lib* /usr/local/cuda/lib64
sudo chmod a+r /usr/local/cuda/include/*
sudo chmod a+r /usr/local/cuda/lib64/lib*

该命令用于把所有cuDNN的相关文件暴力复制到cuda的对应目录下,并修改它们的访问权限。一定要注意一下该命令中的路径,如果路径不对应的话要修改上述命令,比如有些cuDNN的库目录不叫lib而叫lib64

如果大家对操作系统熟悉的话,可以灵活地把复制改为剪切或者软链接。

Anaconda

Anaconda可以让用户更好地管理Python包。反正大家都在用,我也一直在用。

无论是什么操作系统,都可以在这里下Anaconda:
https://www.anaconda.com/products/individual#Downloads

同样,Windows和Linux分别会得到GUI安装器和shell脚本。

下好了安装器后,按照默认配置安装即可。

安装完成后,下一步是打开有Anaconda环境的控制台。

在Windows下,点击任务栏中的搜索框,搜索Anaconda,打开Anaconda Powershell Prompt (Anaconda)或者Anaconda Prompt (Anaconda)

在Linux下,新建一个命令行即可。

如果在命令行里看到了(base),就说明安装成功了。

之后,要创建某个Python版本的虚拟环境,专门放我们用来做深度学习的Python库。该命令如下:

1
conda create --name {env_name} python={version}

比如我要创建一个名字叫pt,Python版本3.7的虚拟环境:

1
conda create --name pt python=3.7

创建完成后,使用下面的命令进入虚拟环境:

1
conda activate {env_name}

我的命令是:

1
conda activate pt

如果在命令行前面看到了({env_name}),就算是成功了:

完成上述步骤后,在VSCode里用ctrl+shift+p打开命令面板,输入select interpreter,找到Python: Select Interpreter这个选项,选择刚刚新建好的虚拟环境中的Python解释器。这样,新建VSCode的控制台时,控制台就能自动进入到conda虚拟环境里了。

PyTorch

推荐直接去官网首页下载。在首页,可以找到稳定版、最新版、长期支持版在不同操作系统下用不同包管理器,不同设备(不同CUDA版本或CPU)的pytorch安装命令:

这里选操作系统和编程语言没什么好讲的,包管理器也是最好选conda。要注意的就是PyTorch版本和CUDA版本。PyTorch版本最好选择稳定版和长期支持版(第一个和第三个)。同时,如前文所述,PyTorch和CUDA有一个大致的对应关系,最好能找到一个版本完美对应的安装命令。如果这里找不到合适的命令,可以去 https://pytorch.org/get-started/previous-versions/ 找旧版PyTorch的安装命令。

比如我要装cuda11.1的LTS版PyTorch,查出来的命令是:

1
conda install pytorch torchvision torchaudio cudatoolkit=11.1 -c pytorch-lts -c nvidia

又比如我要装当前稳定版cuda11.3的PyTorch,查出来的命令是:
1
conda install pytorch torchvision torchaudio cudatoolkit=11.3 -c pytorch

去Anaconda的命令行里执行这样一句安装指令即可。

如果下载速度较慢,请更换conda和pip的下载源。可参考的教程很多,比如 https://blog.csdn.net/u011935830/article/details/10307 95。

如果显卡驱动和conda都装好了,执行完上面的命令后,GPU版PyTorch也就装好了。打开Python,执行下面的命令(或者写一个.py文件再运行),即可验证GPU版安装是否成功。

1
2
import torch
print(torch.cuda.is_available())

如果输出了True,就说明GPU版的PyTorch安装成功了。

用PyTorch实现多分类任务

每当学习一门新的编程技术时,程序员们都会完成一个”Hello World”项目。让我们完成一个简单的点集多分类任务,作为PyTorch的入门项目。这个项目只会用到比较底层的函数,而不会使用框架的高级特性,可以轻松地翻译成纯NumPy或者其他框架的实现。

在这个项目中,我们会学到以下和PyTorch有关的知识:

  • PyTorch与NumPy的相互转换
  • PyTorch的常见运算(矩阵乘法、激活函数、误差)
  • PyTorch的初始化器
  • PyTorch的优化器
  • PyTorch维护梯度的方法

我们将按照程序运行的逻辑顺序,看看这个多分类器是怎么实现的。

如果你看过我其他的代码实战文章,欢迎比较一下这些代码,看看相比NumPy,PyTorch节约了多少代码。同时可以看一看PyTorch和TensorFlow的区别。

欢迎在GitHub上面访问本项目

数据集

本项目中,我们要用到一个平面点数据集。在平面上,有三种颜色不同的点。我们希望用PyTorch编写的神经网络能够区分这三种点。

在项目中,我已经写好了生成数据集的函数。generate_points能根据数据集大小生成一个平面点数据集。generate_plot_set能生成最终测试平面上每一个“像素”的测试集。使用这两个函数,得到的X的形状为[2, m](因为是平面点,所以只有两个通道),Y的形状为[1, m]Y的元素是0-2的标签,分别表示红、绿、蓝三种颜色的点。

1
2
3
4
5
train_X, train_Y = generate_points(400)
plot_X = generate_plot_set()

# X: [2, m]
# Y: [1, m]

数据预处理与PyTorch转换

我们刚刚得到的X, Y都是NumPy数组,我们要把它们转换成PyTorch认识的数据结构。

在PyTorch中,所有参与运算的张量都用同一个类表示,其类型名叫做Tensor。而在构建张量时,我们一般要用torch.tensor这个函数。不要把torch.Tensortorch.tensor搞混了哦。

使用torch.tensor和使用np.ndarray非常类似,一般只要把数据传入第一个参数就行。有需要的话可以设置数据类型。对于train_X,可以用如下代码转换成torch的数据:

1
train_X_pt = torch.tensor(train_X, dtype=torch.float32)

而在使用train_Y时,要做一些额外的预处理操作。在计算损失函数时,PyTorch默认标签Y是一个一维整形数组。而我们之前都会把Y预处理成[1, m]的张量。因此,这里要先做一个维度转换,再转张量:

1
train_Y_pt = torch.tensor(train_Y.squeeze(0), dtype=torch.long)

经过上述操作,X, Y再被送入PyTorch模型之前的形状是:

1
2
3
4
5
print(train_X_pt.shape)
print(train_Y_pt.shape)

# X: [2, m]
# Y: [m]

PyTorch多分类模型

处理完了数据,接下来,我们就要定义神经网络了。在神经网络中,我们要实现初始化、正向传播、误差、评估这四个方法。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MulticlassClassificationNet():
def __init__(self, neuron_cnt: List[int]):
self.num_layer = len(neuron_cnt) - 1
self.neuron_cnt = neuron_cnt
self.W = []
self.b = []
for i in range(self.num_layer):
new_W = torch.empty(neuron_cnt[i + 1], neuron_cnt[i])
new_b = torch.empty(neuron_cnt[i + 1], 1)
torch.nn.init.kaiming_normal_(new_W, nonlinearity='relu')
torch.nn.init.kaiming_normal_(new_b, nonlinearity='relu')
self.W.append(torch.nn.Parameter(new_W))
self.b.append(torch.nn.Parameter(new_b))
self.trainable_vars = self.W + self.b
self.loss_fn = torch.nn.CrossEntropyLoss()

和之前一样,我们通过neuron_cnt指定神经网络包含输出层在内每一层的神经元数。之后,根据每一层的神经元数,我们就可以初始化参数Wb了。

使用PyTorch,我们可以方便地完成一些高级初始化操作。首先,我们用torch.empty生成一个形状正确的空张量。之后,我们调用torch.nn.init.kaiming_normal_的初始化函数。kaiming_normal就是He Initialization。这个初始化方法需要指定激活函数是ReLU还是LeakyReLU。我们之后要用ReLU,所以nonlinearity是那样填的。

初始化完成后,为了让torch知道这几个张量是用可训练的参数,我们把它们
构造成torch.nn.Parameter。这样,torch就会自动更新这些参数了。

最后,我们用self.trainable_vars = self.W + self.b记录一下所有待优化变量,并提前初始化一个交叉熵误差函数,为之后的优化算法做准备

正向传播

正向传播的写法很简单,只要在每层算一个矩阵乘法和一次加法,再经过激活函数即可(在这个神经网络中,隐藏层的激活函数默认使用ReLU):

1
2
3
4
5
6
7
8
9
10
def forward(self, X):
A = X
for i in range(self.num_layer):
Z = torch.matmul(self.W[i], A) + self.b[i]
if i == self.num_layer - 1:
A = F.softmax(Z, 0)
else:
A = F.relu(Z)

return A

在这份代码中,torch.matmul用于执行矩阵乘法,等价于np.dot。和NumPy里的张量一样,PyTorch里的张量也可以直接用运算符+来完成加法。

做完了线性层的运算后,我们可以方便地调用torch.nn.functional里的激活函数完成激活操作。在大多数人的项目中,torch.nn.functional会被导入简称成F。PyTorch里的底层运算函数都在F中,而构造一个函数类(比如刚刚构造的torch.nn.CrossEntropyLoss()再调用该函数类,其实等价于直接去运行F里的函数。

值得一提的是,PyTorch会自动帮我们计算导数。因此,我们不用在正向传播里保存中间运算结果,也不用再编写反向传播函数了。

损失函数

由于之前已经初始化好了误差函数,这里直接就调用就行了:

1
2
def loss(self, Y, Y_hat):
return self.loss_fn(Y_hat.T, Y)

self.loss_fn = torch.nn.CrossEntropyLoss()就是PyTorch的交叉熵误差函数,它也适用于多分类。由于这个函数要求第一个参数的形状为[num_samples, num_classes],和我们的定义相反,我们要把网络输出Y_hat转置一下。第二个输入Y必须是一维整形数组,我们之前已经初始化好了,不用做额外操作,PyTorch会自动把它变成one-hot向量。做完运算后,该函数会自动计算出平均值,不要再手动求一次平均。

评估

为了监控网络的运行结果,我们可以手写一个评估网络正确率和误差的函数:

1
2
3
4
5
6
7
8
9
10
11
def evaluate(self, X, Y, return_loss=False):
Y_hat = self.forward(X)
Y_predict = Y
Y_hat_predict = torch.argmax(Y_hat, 0)
res = (Y_predict == Y_hat_predict).float()
accuracy = torch.mean(res)
if return_loss:
loss = self.loss(Y, Y_hat)
return accuracy, loss
else:
return accuracy

首先,我们使用Y_hat = self.forward(X),根据X算出估计值Y_hat。之后我们就要对YY_hat进行比较了。

Y_hat只记录了分类成各个类别的概率,用向量代表了标签。为了方便比较,我们要把它转换回用整数表示的标签。这个转换函数是torch.argmax

和数学里的定义一样,torch.argmax返回令函数最大的参数值。而对于数组来说,就是返回数组里值最大的下标值。torch.argmax的第一个参数是参与运算的张量,第二个参数是参与运算的维度。Y_hat的形状是[3, m],我们要把长度为3的向量转换回标签向量,因此应该对第一维进行运算(即维度0)。

得到了Y_predict, Y_hat_predict后,我们要比对它们以计算准确率。这时,我们可以用Y_predict == Y_hat_predict得到一个bool值的比对结果。PyTorch的类型比较严格,bool值是无法参与普通运算的,我们要用.float强制类型转换成浮点型。

最后,用accuracy = torch.mean(res)就可以得到准确率了。

由于我们前面写好了loss方法,计算loss时直接调用方法就行了。

模型训练

写完了模型,该训练模型了。下面是模型训练的主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def train(model: MulticlassClassificationNet,
X,
Y,
step,
learning_rate,
print_interval=100):
optimizer = torch.optim.Adam(model.trainable_vars, learning_rate)
for s in range(step):
Y_hat = model.forward(X)
cost = model.loss(Y, Y_hat)
optimizer.zero_grad()
cost.backward()
optimizer.step()

PyTorch使用一系列的优化器来维护梯度下降的过程。我们只需要用torch.optim.Adam(model.trainable_vars, learning_rate)即可获取一个Adam优化器。构造优化器时要输入待优化对象,我们已经提前存好了。

接下来,我们看for s in range(step):里每一步更新参数的过程。

在PyTorch里,和可学习参数相关的计算所构成的计算图会被动态地构造出来。我们只要普通地写正向传播代码,求误差即可。

执行完cost = model.loss(Y, Y_hat),整个计算图就已经构造完成了。我们调用optimizer.zero_grad()清空优化器,用cost.backward()自动完成反向传播并记录梯度,之后用optimizer.step()完成一步梯度下降。

可以看出,相比完全用NumPy实现,PyTorch用起来十分方便。只要我们用心定义好了前向传播函数和损失函数,维护梯度和优化参数都可以交给编程框架来完成。

实验

做完了所有准备后,我们用下面的代码初始化模型并调用训练函数

1
2
3
4
n_x = 2
neuron_list = [n_x, 10, 10, 3]
model = MulticlassClassificationNet(neuron_list)
train(model, train_X_pt, train_Y_pt, 5000, 0.001, 1000)

这里要注意一下,由于数据有三种类别,神经网络最后一层必须是3个神经元。

网络训练完成后,我们用下面的代码把网络推理结果转换成可视化要用的NumPy结果:

1
2
3
plot_result = model.forward(torch.Tensor(plot_X))
plot_result = torch.argmax(plot_result, 0).numpy()
plot_result = np.expand_dims(plot_result, 0)

运行完plot_result = model.forward(torch.Tensor(plot_X))后,我们得到的是一个[3, m]的概率矩阵。我们要用torch.argmax(plot_result, 0)把它转换回整型标签。

之后,我们对PyTorch的张量调用.numpy(),即可使用我们熟悉的NumPy张量了。为了对齐可视化API的格式,我用expand_dims把最终的标签转换成了[1, m]的形状。

完成了转换,只需调用我写的可视化函数即可看出模型是怎样对二维平面分类的:

1
visualize(train_X, train_Y, plot_result)

我的一个运行结果如下:

只能说,神经网络实在太强啦。

总结

在这篇笔记中,我介绍了PyTorch在Windows/Linux下的从零安装方法,并且介绍了一个简单的PyTorch多分类项目。希望大家能通过这篇笔记,成功上手PyTorch。

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

在过去的三周,我们学完了《深度学习专项》的第二门课《改进深度神经网络:调整超参数、正则化和优化》。这些知识十分零散,让我们用点技能点的方式回顾一下这些知识,同时评测一下自己的学习情况。复习完了后,我们来看看下一门课的学习内容。

第二阶段回顾

在本节中,你需要记下两个数字:技能点数和觉醒技能点数。

技能点获取规则:必须先点完基础的知识,再点进阶的知识。同级知识之间没有先后限制。同时,某些知识还有额外的前置条件。

数据集划分

浅尝(+1点)

  • 数据集可以分成训练集、开发集、测试集三种。
  • 数据量小时按比例划分,数据量大时只需要选少量数据用作开发集、测试集。
  • 开发集和测试集的区别:开发模型时不能偷看测试集的评估结果。

偏差与方差

浅尝(+1点)

  • 理解偏差和方差的基本意义。
  • 能用二维点集分类的例子描述偏差问题和方差问题。
  • 在知道了模型在训练集、开发集上的误差后,能够诊断模型存在的问题。

精通 - 解决偏差与方差问题(+1点)

前置技能点:正则化

  • 面对偏差问题,常见的解法是使用的更复杂的模型提升参数量,并延长训练时间。
  • 面对方差问题,常见的解法是增加数据(数量和质量)和正则化。
  • 改变模型结构往往能同时解决这两个问题。

正则化

浅尝(+1点)

前置技能点:了解方差

  • 正则化的作用:缓解过拟合。
  • 正则化的通用思想:防止网络过分依赖少量的某些参数。

入门 - 添加正则化项(+1点)

在损失函数新加一项:

梯度下降时稍微修改一下参数的更新方法:

精通 - dropout(+1点)

  • dropout的思想:训练时随机丢掉某些激活输出。
  • dropout的实现:由随机数矩阵和失活概率算出一个bool矩阵,以此bool矩阵为mask与激活输出相乘。

博闻 - 其他正则化方法(+1点)

  • 数据增强。
  • 提前停止(early stopping)。
  • ……

若能够实现正则化项、dropout、提前停止,则获得1觉醒点

参数初始化

浅尝(+0点)

  • 知道参数要用比较小的值初始化。

入门(+1点)

  • 了解梯度数值异常的原因:中间计算结果随网络层数指数级变化。
  • 参数初始化可以令数据的方差尽可能靠近1,防止梯度异常问题。

精通(+1点)

  • 知道如何添加参数初始化系数。
  • 了解常见的初始化系数的选择方法,比如He、Xavier。

博闻(+1点)

  • 看参数初始化的原论文,深入理解参数初始化的原理。

梯度检查

精通(+1点)

  • 知道梯度检查的数学公式。
  • 实现简单模型的梯度检查。

博闻(+1点)

  • 会用编程框架里的梯度检查以调试大模型里的梯度。

mini-batch

入门(+1点)

  • 知道mini-batch是怎么根据batchsize划分训练集的。
  • 能够实现mini-batch。

精通(+2点)

  • mini-batch的加速原理:增加参数更新次数,同时不影响向量化运算的性能。
  • 在实验中体会不同batchsize的效果,能灵活选择batchsize。

指数加权移动平均

入门(+1点)

  • 移动平均数的作用。
  • 指数加权移动平均的公式:$v_i=\beta v_{i-1} + (1 - \beta)t_i$。

精通(+1点)

  • 大概明白为什么使用指数加权移动平均而不使用普通的移动平均。
  • 偏差矫正的原理和实现。

高级梯度下降算法

前置知识:指数加权移动平均

精通 - Momentum(+1点)

  • 大致理解Momentum的思想。
  • 掌握公式,能用代码实现,知道一般情况下超参数$\beta=0.9$。

精通 - RMSProp(+0点)

  • 掌握公式,能用代码实现,知道有哪些超参数。

精通 - Adam(+2点)

前置知识:Momentum, RMSProp

  • 掌握公式,能用代码实现,知道一般情况下超参数$\beta_1=0.9, \beta_2=0.999, \epsilon=10^{-8}$,基本不需要调参。

博闻(+1点)

  • 阅读经典优化器的论文。
  • 了解各优化器的由来,能直观理解各种优化算法的意义。

实现Adam后,获得1觉醒点

学习率衰减

精通(+1点)

  • 知道学习率衰减的意义。
  • 了解几个常见的学习率衰减公式。

尝试使用Mini-batch、高级优化算法、学习率衰减训练网络,比较各类改进梯度下降方法的效果,则获得1觉醒点

调参

浅尝(+1点)

  • 明确自己的模型里有哪些超参数。
  • 大概知道超参数的优先级,会先去尝试调学习率。

入门(+1点)

  • 调参整体思想:随机选数,由粗至精。
  • 不要均匀采样,而要根据参数的意义选择合适的搜索尺度。

批归一化

浅尝(+0点)

  • 知道批归一化的存在。
  • 知道深度学习框架有时默认附带批归一化操作。

入门(+1点)

  • 知道批归一化的意义,与输入归一化的异同。
  • 知道批归一化层有两个超参数。

精通(+1点)

  • 知道批归一化的数学公式(正向传播、反向传播)。
  • 知道批归一化在测试时的用法。

精通II(+1点)

  • 动手实现批归一化

博闻(+1点)

完成入门即可学习。

  • 了解其他的几种归一化(layer, group)。
  • 知道不同归一化方法的优劣。

多分类问题

浅尝(+1点)

  • 多分类问题的定义。
  • 知道多分类问题的输出、训练集标签与二分类有什么不同。

精通 - softmax(+1点)

  • softmax的公式定义。
  • 如何在网络中使用softmax。
  • 大致了解softmax为什么要做一步指数运算。

精通II - 实现带softmax的多分类网络(+1点)

  • one-hot编码转换。
  • 实现多分类网络。
  • 利用one-hot处理标签和输出结果,正确评测多分类网络。

初识Tensorflow

浅尝 - 编程框架(+1点)

  • 知道编程框架能做什么事。
  • 认识常见的编程框架。
  • 知道选择编程框架的原则。

入门(+1点)

  • 安装GPU版的TensorFlow。

精通(+2点,+1觉醒点)

前置知识:多分类问题

  • 使用TensorFlow实现多分类网络。

第二阶段自评

在“回顾”一节中,共35个普通技能点,4个觉醒技能点。普通技能点主要表示课堂知识,以及少量的课堂上没讲到的可拓展知识点(我自己也拿不满),觉醒技能点主要表示对知识的综合实现与应用。让我们根据自己获得的技能点数,看看自己的学习情况。

Level 0:乱搞一通

条件:技能点≤5

评价:随便去拷贝了几份深度学习代码,跑通了,就以为自己会深度学习了。丝毫不去关心深度学习的基础知识。这样下去,学习和应用更难的深度学习技术时肯定会碰到很多困难。

Level 1:初来乍到

条件:6≤技能点≤15

预估学习情况:大致获取了6个浅尝技能点和7个入门技能点,没有对知识做进一步的思考和实现。

评价:要学会使用深度学习编程框架,甚至复现出一些经典模型,了解入门知识就足够了。尽管如此,钻研更深的知识对深度学习项目的开发还是有很大的帮助的。

Level 2:学有所成

条件:16≤技能点≤22。至少有1个觉醒点才能升级到Level 2。

预估学习情况:完全获取了6个浅尝技能点和7个入门技能点,深入理解了部分知识,进行过代码实现。

评价:非常棒!相信在这一过程中,你已经对部分知识有了更深的理解。第二阶段的所有知识都很重要,建议坚持下去,把所有知识都探究完。

Level 3:登堂入室

条件:23≤技能点≤32。至少有3个觉醒点才能升级到Level 3。

预估学习情况:完全获取了6个浅尝技能点和7个入门技能点,基本获取了17个精通知识点,对部分自己感兴趣的知识做了额外的探究。

评价:恭喜!学到这里,你可以说自己已经完全掌握了第二阶段的知识了。同时,在多个代码实现项目中,你也锻炼了编程能力,从代码的角度近距离接触了各项知识。相信这些学习经验会对你未来的学习和应用产生莫大的帮助。

Level 4:学无止境

条件:33≤技能点≤35。至少有4个觉醒点才能升级到Level 4。

预估学习情况:学懂了除博闻外所有的知识点,对课堂中没有详细介绍的知识做了补充学习。同时,完成了大量的编程练习。

评价:很强。能做到这一步,说明你对深度学习的学习充满了兴趣。相信这一兴趣能够帮助在未来的学习中走得更远。

成就

此外,还要颁发两个成就:

编程狂魔:获取4个觉醒点。

百科全书:获取5个博闻知识点。

第三阶段知识预览

经过了三周紧张的学习,我们学到了非常多硬核的深度学习知识,还完成了不少编程项目。

在《深度学习专项》的下一门课《组织深度学习项目》中,我们会用两周时间,轻松地学一些不那么困难的知识:

  • 机器学习改进策略的宗旨
    • 正交化
  • 设置改进目标
    • 评估指标
    • 数据集划分的细节
  • 与人类表现比较
    • 为什么使用人类的表现
    • 理解并利用人类表现
    • 试图改进模型以超过人类的表现
  • 差错分析
    • 分析开发误差的由来
    • 清理错标数据
  • 不匹配的训练与开发/测试集
    • 如何使用不同分布的数据
    • 如何在这种情况下评估偏差与方差
    • 解决数据不匹配问题
  • 完成多个任务
    • 迁移学习
    • 多任务学习
  • 端到端学习
    • 什么是端到端学习
    • 何时用端到端学习

从标题中也能大致看出,这些知识基本不涉及任何复杂的数学公式,学习起来应该会很轻松。不过,了解这些知识也是很有必要的。在搭建一个能解决实际问题的深度学习项目时,这些组织深度学习项目的经验往往能帮助到我们。让我们做好准备,迎接新课程的学习。

安装Tensorflow

前言

配编程环境考察的是利用搜索引擎的能力。在配环境时,应该多参考几篇文章。有英文阅读能力的应该去参考官方给的配置教程。出了问题把问题的出错信息放到搜索引擎上去查。一般多踩几次坑,多花点时间,环境总能配好。

本文只能给出一个大概率可行的指导,不能覆盖所有情况。如果在执行本文的安装步骤时出了问题,请灵活使用搜索引擎。

配置深度学习编程框架时,强烈推荐配置GPU版本。本文会介绍TensorFlow GPU版本的配置。如果只想用CPU版本的话,跳过“CUDA与cuDNN”一节即可。

本文会同时介绍Windows和Linux下的安装方法。二者操作有区别的地方本文会特别强调,若没有强调则默认二者处理方法一致。

CUDA与cuDNN

CUDA是NVIDIA显卡的GPU编程语言。cuDNN是基于CUDA编写的GPU深度学习编程库。在使用深度学习编程框架时,我们一般都要装好CUDA和cuDNN。

这个安装步骤主要分三步:

  1. 装显卡驱动
  2. 装CUDA
  3. 装cuDNN

其中,显卡驱动一般不需要手动安装,尤其是在自带了NVIDIA显卡的Windows电脑上。

显卡驱动

nvidia-smi查看电脑的CUDA驱动最高支持版本。下图标出了命令运行成功后该信息所在位置:

如果命令能成功运行,记住这个信息。

如果这个命令失败了,就说明电脑需要重新安装显卡驱动。现在(2022年)CUDA的主流版本都是11.x,如果你发现驱动支持的最高版本偏低,也可以按照下面的步骤重新安装显卡驱动。

访问NVIDIA驱动官网:https://www.nvidia.cn/geforce/drivers/ 。在网站上,输入显卡型号和操作系统等信息,即可找到对应的驱动安装程序。

对于Windows,下载的是一个有GUI的安装器;对于Linux,下载的是一个shell脚本。如果你用的是Linux服务器,没有图形接口,可以先复制好下载链接,之后用wget下载脚本。

之后,运行安装器,按照指引即可完成驱动的安装。

注意,如果是带图形界面的Linux系统,可能要关闭图像界面再安装驱动。比如对于Ubuntu,一般要关闭nouveau再重启。请参考 https://zhuanlan.zhihu.com/p/59618999 等专门介绍Ubuntu显卡驱动安装的文章。

能够执行nvidia-smi后,执行该命令,找到驱动支持的最高CUDA版本。

CUDA

首先,我们要定一个CUDA安装版本。

CUDA安装版本的第一个限制是,该版本不能大于刚刚在nvidia-smi中获取的最高CUDA版本。

第二个限制是,TensorFlow版本必须支持当前CUDA版本。在 https://www.tensorflow.org/install/source#gpu 中,可以找到TensorFlow与CUDA、cuDNN的版本对应表。这个表格仅表示了经过测试的CUDA版本,不代表其他CUDA版本就一定不行。

由于开发环境中可能会安装多个编程框架(TensorFlow,PyTorch),建议先安装一个比较常用、版本较高的CUDA,比如CUDA 11.1,11.2之类的。之后,让编程框架向CUDA版本妥协。

如果之后安装TensorFlow后发现CUDA版本不对应,可以尝试升级TensorFlow版本。如果TensorFlow实在是支持不了当前的CUDA版本,最后再考虑降级当前的CUDA版本。

选好了CUDA版本后,去 https://developer.nvidia.com/cuda-toolkit-archive 上下载CUDA安装器。同样,Windows和Linux分别会得到GUI安装器和shell脚本。

装完CUDA后,再控制台上输入nvcc -Vnvcc是CUDA专用的编译器,-V用于查询版本。如果这个命令能够运行,就说明CUDA已经装好了。以下是nvcc -V的输出:

cuDNN

打开下载网站 https://developer.nvidia.com/rdp/cudnn-download (最新版本) 或 https://developer.nvidia.com/rdp/cudnn-archive (历史版本)。注册账号并登录。

根据CUDA版本,找到合适版本的cuDNN。https://docs.nvidia.com/deeplearning/cudnn/archives/index.html 这个网站列出了每个cuDNN版本支持的CUDA版本(Support Matrix)。一般来说,可以去找最新的cuDNN,看它是否兼容当前的CUDA版本。如果不行,再考虑降级cuDNN。一般来说,CUDA 11.x 的兼容性都很好。

选好了cuDNN版本后,去上面的下载网站上下载最新或某个历史版本的cuDNN。注意,应该下载一个压缩文件,而不应该下载一个可执行文件。比如对于所有的Linux系统,都应该下载”xxx for Linux x86_64 (Tar)”

装CUDA和cuDNN,主要的目的是把它们的动态库放进环境变量里,把头文件放到系统头文件目录变量里。因此,下一步,我们要把cuDNN的文件放到系统能够找到的地方。由于CUDA的库目录、包含目录都会在安装时自动设置好,一种简单的配置方法是把cuDNN的文件放到CUDA的对应目录里。

对于Windows,我们要找到CUDA的安装目录,比如C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.2。再找到刚刚cuDNN解压后的目录,比如D:\Download\cudnn-11.1-windows-x64-v8.0.4.30\cuda。把cuDNN目录下bin、include、lib里的文件分别复制到CUDA目录的对应文件夹中。

对于Linux,CUDA的安装目录一般是/usr/local/cuda。再找到cuDNN的解压目录,比如~/Downloads/cudnn-linux-x86_64-8.4.0.27_cuda11.6-archive。切换到cuDNN的根目录下,输入类似下面的命令:

1
2
3
4
sudo cp include/* /usr/local/cuda/include
sudo cp lib/lib* /usr/local/cuda/lib64
sudo chmod a+r /usr/local/cuda/include/*
sudo chmod a+r /usr/local/cuda/lib64/lib*

该命令用于把所有cuDNN的相关文件暴力复制到cuda的对应目录下,并修改它们的访问权限。一定要注意一下该命令中的路径,如果路径不对应的话要修改上述命令,比如有些cuDNN的库目录不叫lib而叫lib64

如果大家对操作系统熟悉的话,可以灵活地把复制改为剪切或者软链接。

Anaconda

Anaconda可以让用户更好地管理Python包。反正大家都在用,我也一直在用。

无论是什么操作系统,都可以在这里下Anaconda:
https://www.anaconda.com/products/individual#Downloads

同样,Windows和Linux分别会得到GUI安装器和shell脚本。

下好了安装器后,按照默认配置安装即可。

安装完成后,下一步是打开有Anaconda环境的控制台。

在Windows下,点击任务栏中的搜索框,搜索Anaconda,打开Anaconda Powershell Prompt (Anaconda)或者Anaconda Prompt (Anaconda)

在Linux下,新建一个命令行即可。

如果在命令行里看到了(base),就说明安装成功了。

之后,要创建某个Python版本的虚拟环境,专门放我们用来做深度学习的Python库。该命令如下:

1
conda create --name {env_name} python={version}

比如我要创建一个名字叫pt,Python版本3.7的虚拟环境:

1
conda create --name pt python=3.7

创建完成后,使用下面的命令进入虚拟环境:

1
conda activate {env_name}

我的命令是:

1
conda activate pt

如果在命令行前面看到了({env_name}),就算是成功了:

完成上述步骤后,在VSCode里用ctrl+shift+p打开命令面板,输入select interpreter,找到Python: Select Interpreter这个选项,选择刚刚新建好的虚拟环境中的Python解释器。这样,新建VSCode的控制台时,控制台就能自动进入到conda虚拟环境里了。

TensorFlow

无论是GPU版还是CPU版,只需要在对应的虚拟环境中输入下面的命令即可:

1
pip install tensorflow

如果下载速度较慢,请更换conda和pip的下载源。可参考的教程很多,比如 https://blog.csdn.net/u011935830/article/details/10307 95。

如果显卡驱动和conda都装好了,执行完上面的命令后,GPU版TensorFlow也就装好了。打开Python,执行下面的命令(或者写一个.py文件再运行),即可验证GPU版安装是否成功。

1
2
import tensorflow as tf
tf.config.list_physical_devices('GPU')

如果最后输出了一大堆信息,最后一行是

1
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]

,那么就说明GPU版的TensorFlow安装成功了。

VSCode代码补全

TensorFlow.keras在VSCode中无法生成代码补全,编程体验极差,不知道维护者在干什么东西。有人在issue中提出了解决方法。

打开tensorflow/__init__.py,添加以下内容:

1
2
3
4
5
6
7
if _typing.TYPE_CHECKING:
from tensorflow_estimator.python.estimator.api._v2 import estimator as estimator
from keras.api._v2 import keras
from keras.api._v2.keras import losses
from keras.api._v2.keras import metrics
from keras.api._v2.keras import optimizers
from keras.api._v2.keras import initializers

用TensorFlow实现多分类任务

每当学习一门新的编程技术时,程序员们都会完成一个”Hello World”项目。让我们完成一个简单的点集多分类任务,作为TensorFlow的入门项目。这个项目只会用到比较底层的函数,而不会使用框架的高级特性,可以轻松地翻译成纯NumPy或者其他框架的实现。

在这个项目中,我们会学到以下和TensorFlow有关的知识:

  • TensorFlow与NumPy的相互转换
  • TensorFlow的常量与变量
  • TensorFlow的常见运算(矩阵乘法、激活函数、误差)
  • TensorFlow的初始化器
  • TensorFlow的优化器
  • TensorFlow保存梯度中间结果的方法
  • one-hot与标签的相互转换

我们将按照程序运行的逻辑顺序,看看这个多分类器是怎么实现的。

如果你看过我前几周的代码实战文章,欢迎比较一下这周和之前的代码,看看相比NumPy,TensorFlow节约了多少代码。

欢迎在GitHub上面访问本项目

数据集

这周,我们要用到一个平面点数据集。在平面上,有三种颜色不同的点。我们希望用TensorFlow编写的神经网络能够区分这三种点。

在项目中,我已经写好了生成数据集的函数。generate_points能根据数据集大小生成一个平面点数据集。generate_plot_set能生成最终测试平面上每一个“像素”的测试集。使用这两个函数,得到的X的形状为[2, m](因为是平面点,所以只有两个通道),Y的形状为[1, m]Y的元素是0-2的标签,分别表示红、绿、蓝三种颜色的点。

1
2
3
4
5
train_X, train_Y = generate_points(400)
plot_X = generate_plot_set()

# X: [2, m]
# Y: [1, m]

数据预处理与TensorFlow转换

我们刚刚得到的X, Y都是NumPy数组,我们要把它们转换成TensorFlow认识的数据结构。

TensorFlow用起来和C++很像,我们要决定一个数据是变量还是常量。由于X是不可变的训练数据,它应该属于常量。因此,我们用下面的语句把它转换成TensorFlow的常量。

1
train_X_tf = tf.constant(train_X, dtype=tf.float32)

TensorFlow常量的类型名叫做tf.Tensor,也就是说train_X_tf是一个tf.Tensor

而在使用Y时,我们要加一步转换到one-hot编码的步骤。回忆本周笔记中有关多分类loss的知识,这里的Y是一个整型数组,表示每个数据的类别。而在loss的计算中,我们需要把每个整数转换成一个one-hot向量,得到一个one-hot向量的向量。

因此,我们可以用下面的代码把Y预处理并转换成TensorFlow的数据结构:

1
train_Y_tf = tf.transpose(tf.one_hot(train_Y.squeeze(0), 3))

tf.one_hot()用于生成one-hot编码,其第二个参数为总类别数。我们的数据集有3种点,因此取3。tf.one_hot()的输出是一个[m, 3]形状的张量,我们要把它tf.transpose转置一下,得到与其他代码相匹配的[3, m]张量。

顺带一提,由于tf.one_hot是一个TensorFlow的运算,如果输入是一个numpy数组,输出会被自动转换成一个TensorFlow的常量tf.Tensor。所以,Y的类型也是tf.Tensor

经过上述操作,X, Y再被送入TensorFlow模型之前的形状是:

1
2
# X: [2, m]
# Y: [3, m]

TensorFlow多分类模型

处理完了数据,接下来,我们就要定义神经网络了。在神经网络中,我们要实现初始化、正向传播、误差、评估这四个方法。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MulticlassClassificationNet():

def __init__(self, neuron_cnt: List[int]):
self.num_layer = len(neuron_cnt) - 1
self.neuron_cnt = neuron_cnt
self.W = []
self.b = []
initializer = tf.keras.initializers.HeNormal(seed=1)
for i in range(self.num_layer):
self.W.append(
tf.Variable(
initializer(shape=(neuron_cnt[i + 1], neuron_cnt[i]))))
self.b.append(
tf.Variable(initializer(shape=(neuron_cnt[i + 1], 1))))
self.trainable_vars = self.W + self.b

和之前一样,我们通过neuron_cnt指定神经网络包含输出层在内每一层的神经元数。之后,根据每一层的神经元数,我们就可以初始化参数Wb了。

使用TensorFlow,我们可以方便地完成一些高级初始化操作。比如我们要使用He Initialization,我们可以用tf.keras.initializers.HeNormal(seed=1)生成一个初始化器initializer,再用这个工具生成每一个初始化后的变量。

使用initializer(*shape)即可生成某形状的参数。由于参数是需要被优化更新的,我们需要用tf.Variable来把参数转换成可以优化的变量。

最后,我们用self.trainable_vars = self.W + self.b记录一下所有待优化变量,为之后的优化算法做准备。

正向传播

正向传播的写法很简单,只要在每层算一个矩阵乘法和一次加法,再经过激活函数即可(在这个神经网络中,隐藏层的激活函数默认使用ReLU):

1
2
3
4
5
6
7
8
def forward(self, X):
A = X
for i in range(self.num_layer):
Z = tf.matmul(self.W[i], A) + self.b[i]
if i == self.num_layer - 1:
A = tf.keras.activations.softmax(Z)
else:
A = tf.keras.activations.relu(Z)

在这份代码中,tf.matmul用于执行矩阵乘法,等价于np.dot。和NumPy里的张量一样,TensorFlow里的张量也可以直接用运算符+来完成加法。

做完了线性层的运算后,我们可以方便地调用tf.keras.activations里的激活函数完成激活操作。

值得一提的是,TensorFlow会自动帮我们计算导数。因此,之前我们在正向传播里保存中间运算结果的代码全都可以删掉。我们也不用再编写反向传播函数了。

损失函数

使用下面的代码可以在一行内算完损失函数:

1
2
3
4
def loss(self, Y, Y_hat):
return tf.reduce_mean(
tf.keras.losses.categorical_crossentropy(
tf.transpose(Y),tf.transpose(Y_hat)))

tf.keras.losses.categorical_crossentropy就是多分类使用的交叉熵误差。由于这个函数要求输入的形状为[num_samples, num_classes],和我们的定义相反,我们要把两个输入都转置一下。算完误差后,我们用tf.reduce_mean算误差的平均数以得到最终的损失函数。这个函数等价于NumPy里用mean时令keepdims=False

评估

为了监控网络的运行结果,我们可以手写一个评估网络正确率和误差的函数:

1
2
3
4
5
6
7
8
9
10
11
def evaluate(self, X, Y, return_loss=False):
Y_hat = self.forward(X)
Y_predict = tf.argmax(Y, 0)
Y_hat_predict = tf.argmax(Y_hat, 0)
res = tf.cast(Y_predict == Y_hat_predict, tf.float32)
accuracy = tf.reduce_mean(res)
if return_loss:
loss = self.loss(Y, Y_hat)
return accuracy, loss
else:
return accuracy

首先,我们使用Y_hat = self.forward(X),根据X算出估计值Y_hat。之后我们就要对YY_hat进行比较了。

YY_hat都不是整数标签,而是用向量代表了标签。为了方便比较,我们要把它们转换回用整数表示的标签。这个转换函数是tf.argmax

和数学里的定义一样,tf.argmax返回令函数最大的参数值。而对于数组来说,就是返回数组里值最大的下标值。tf.argmax的第一个参数是参与运算的张量,第二个参数是参与运算的维度。YY_hat的形状是[3, m],我们要把长度为3的向量转换回标签向量,因此应该对第一维进行运算(即维度0)。

得到了Y_predict, Y_hat_predict后,我们要比对它们以计算准确率。这时,我们可以用res = Y_predict == Y_hat_predict得到一个bool值的比对结果。TensorFlow的类型非常严格,bool值是无法参与普通运算的,我们要用tf.cast强制类型转换。由于最终的准确率是一个浮点数,我们要转换成tf.float32浮点类型。

最后,用accuracy = tf.reduce_mean(res)就可以得到准确率了。

由于我们前面写好了loss方法,计算loss时直接调用方法就行了。

模型训练

写完了模型,该训练模型了。下面是模型训练的主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def train(model: MulticlassClassificationNet,
X,
Y,
step,
learning_rate,
print_interval=100):
optimizer = tf.keras.optimizers.Adam(learning_rate)
for s in range(step):
with tf.GradientTape() as tape:
Y_hat = model.forward(X)
cost = model.loss(Y, Y_hat)
grads = tape.gradient(cost, model.trainable_vars)
optimizer.apply_gradients(zip(grads, model.trainable_vars))

TensorFlow使用一系列的优化器来维护梯度下降的过程。我们只需要用tf.keras.optimizers.Adam(learning_rate)即可获取一个Adam优化器。

接下来,我们看for s in range(step):里每一步更新参数的过程。

在TensorFlow里,为了计算梯度,我们要使用一个上下文with tf.GradientTape() as tape:。在这个上下文中,执行完运算后,所有Variable的求导中间结果都会被记录下来。因此,我们应该调用网络的前向传播和损失函数,完成整套的计算过程。

计算出损失函数后,我们用grads = tape.gradient(cost, model.trainable_vars)算出最终的梯度,并调用optimizer.apply_gradients(zip(grads, model.trainable_vars))更新参数。

可以看出,相比完全用NumPy实现,TensorFlow用起来十分方便。只要我们用心定义好了前向传播函数和损失函数,维护梯度和优化参数都可以交给编程框架来完成。

实验

做完了所有准备后,我们用下面的代码初始化模型并调用训练函数

1
2
3
4
n_x = 2
neuron_list = [n_x, 10, 10, 3]
model = MulticlassClassificationNet(neuron_list)
train(model, train_X_tf, train_Y_tf, 5000, 0.001, 1000)

这里要注意一下,由于数据有三种类别,神经网络最后一层必须是3个神经元。

网络训练完成后,我们用下面的代码把网络推理结果转换成可视化要用的NumPy结果:

1
2
3
plot_result = model.forward(plot_X)
plot_result = tf.argmax(plot_result, 0).numpy()
plot_result = np.expand_dims(plot_result, 0)

运行完plot_result = model.forward(plot_X)后,我们得到的是一个[3, m]的概率t矩阵。我们要用tf.argmax(plot_result, 0)把它转换回整型标签。

之后,我们对TensorFlow的张量调用.numpy(),即可使用我们熟悉的NumPy张量了。为了对齐可视化API的格式,我用expand_dims把最终的标签转换成了[1, m]的形状。

完成了转换,只需调用我写的可视化函数即可看出模型是怎样对二维平面分类的:

1
visualize(train_X, train_Y, plot_result)

我的一个运行结果如下:

只能说,神经网络实在太强啦。

附录:TensorFlow的GPU版本

在使用TensorFlow时,我唯一发现它比PyTorch更便捷的地方,就是TensorFlow能够自动选择运算时的设备。如果电脑按上面的流程装好了驱动、CUDA和cuDNN,TensorFlow就会很主动地把张量放到GPU上运算。而如果没有检测到GPU,TensorFlow也会用CPU计算。

如果想要手动管理张量的运算设备,可以参考下面的代码。当我想在CPU上初始化张量时:

1
2
3
with tf.device('/CPU:0'):
a = tf.constant([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
b = tf.constant([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])

想初始化多个GPU中的某个GPU上的张量:
1
2
3
with tf.device('/device:GPU:2'):
a = tf.constant([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
b = tf.constant([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])

这里GPU的名称可以用我们之前见过的tf.config.list_physical_devices('GPU')来查找:

1
2
>>> tf.config.list_physical_devices('GPU')
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]

有趣的是,这个项目的代码用TensorFlow在GPU上运行,比我之前的NumPy项目用CPU运行还慢。感觉是这个项目的计算过于简单,GPU无法发挥性能上的优势。GPU计算的一些其他开销盖过了运算时间的减少。

总结

在这篇笔记中,我介绍了TensorFlow在Windows/Linux下的从零安装方法,并且介绍了一个简单的TensorFlow多分类项目。希望大家能通过这篇笔记,成功上手TensorFlow。

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

学习提示

这周的知识点也十分分散,主要包含四项内容:调参、批归一化、多分类任务、编程框架。

通过在之前的编程项目里调整学习率,我们能够体会到超参数对模型效果的重要影响。实际上,选择超参数不是一个撞运气的过程。我们应该有一套系统的方法来快速找到合适的超参数。

两周前,我们学习了输入归一化。类似地,如果对网络的每一层都使用归一化,也能提升网络的整体表现。这样一种常用的归一化方法叫做批归一化。

之前,我们一直都在讨论二分类问题。而只要稍微修改一下网络结构和激活函数,我们就能把二分类问题的算法拓展到多分类问题上。

为了提升编程的效率,从这周开始,我们要学习深度学习编程框架。编程框架往往能够帮助我们完成求导的功能,我们可以把精力集中在编写模型的正向传播上。

课堂笔记

调参

调参的英文动词叫做tune,这个单词作动词时大部分情况下是指调音。这样一看,把调参叫做“调整参数”或“调试参数”都显得很“粗鲁”。理想情况下,调参应该是一个系统性的过程,就像你去给乐器调音一样。乱调可是行不通的。

超参数优先级

回顾一下,我们接触过的超参数有:

  • 学习率 $\alpha$
  • momentum $\beta$
  • adam $\beta_1, \beta_2, \epsilon$
  • 隐藏层神经元数
  • 层数
  • 学习率递减率
  • mini-batch size

其中,优先级最高的是学习率。吴恩达老师建议大家调完学习率后,再去调$\beta$、隐藏层神经元数、mini-batch size。如果使用adam,则它的三个参数基本不用调。

超参数采样策略

在尝试各种超参数时,不要按“网格”选参数(如下图左半所示),最好随机选参数(如下图右半所示):

如果用网格采样法的话,你可能试了25组参数,每个参数只试了5个不同的值。而实际上,你试的两个参数中只有一个参数对结果的影响较大,另一个参数几乎不影响结果。最终,你尝试的25次中只有5次是有效的。

而采用随机采样法试参数的话,你能保证每个参数在每次尝试时都取不同值。这样试参数的效率会更高一点。

另外,调参时还有一个“由粗至精”的过程。如下图所示:

当我们发现某几个参数的结果比较优秀时,我们可以缩小搜索范围,仅在这几个参数附近进行搜索。

超参数搜索尺度

搜索参数时,要注意搜索的尺度。如果搜索的尺度不够恰当,我们大部分的调参尝试可能都是无用功。

比如当搜索学习率时,我们应该按0.0001, 0.001, 0.01, 0.1, 1这样指数增长的方式去搜索,而不应该按0.2, 0.4, 0.6, 0.8, 1这种均匀采样的方式搜索。这是因为学习率是以乘法形式参与计算,取0.4, 0.6, 0.8得到的结果可能差不多,按这种方式采样的话,大部分的尝试都是浪费的。而以0.001, 0.01, 0.1这种方式取学习率的话,每次的运行结果就会差距较大,每次尝试都是有意义的。

除了搜索学习率时用到的指数采样,还有其他的采样方式。让我们看调整momentum项$\beta$的情况。回忆一下,$\beta$取0.9,表示近10项的平均数;$\beta$取0.99,表示近100项的平均数。也就是说,$\beta$表示$\frac{1}{1-\beta}$项的平均数。我们可以对$\frac{1}{1-\beta}$进行指数均匀采样。

当然,有些参数是可以均匀采样的。比如隐藏层的个数,我们可以从[2, 3, 4]里面挑一个;比如每个隐藏层的神经元数,我们也可以直接均匀采样。

总结一下,我们在搜索超参数的时候,应该从超参数所产生的影响出发,考虑应该在哪个指标上均匀采样,再反推超参数的采样公式,而不一定要对超参数本身均匀采样。

当然了,如果我们不确定应该从哪个尺度对超参数采样,可以先默认使用均匀采样。因为我们会遵循由粗至精的搜索原则,尝试几轮后我们就能够观察出超参数的取值规律,从而在正确的尺度上对超参数进行搜索。

批归一化(Batch Normalization)

第五篇笔记中,我们曾学习了输入归一化。其计算公式如下:

通过归一化,神经网络第一层的输入更加规整,模型的训练速度能得到有效提升。

我们知道,神经网络的输入可以看成是第零层(输入层)的激活输出。一个很自然的想法是:我们能不能把神经网络每一个隐藏层的激活输出也进行归一化,让神经网络更深的隐藏层也能享受到归一化的加速?

批归一化(Batch Normalization)就是这样一种归一化神经网络每一个隐藏层输出的算法。准确来说,我们归一化的对象不是每一层的激活输出$a^{[l]}$,而是激活前的计算结果$z^{[l]}$。让我们看看对于某一层的激活前输出$z=z^{[l]}$,我们该怎么进行批归一化。

首先,还是先获取符合标准正态分布的归一化结果$z^{(i)}_{norm}$:

我们不希望每一层的输出都固定为标准正态分布,而是希望网络能够自己选择最恰当的分布。因此,我们可以用下式计算最终的批归一化结果:

其中$\tilde{z}^{(i)}$是最终的批归一化结果,$\gamma, \beta$都是可学习参数,分别影响新分布的方差与均值。

为什么我们不希望数据的分布总是标准正态分布呢?可以考察一个即将送入sigmoid的$z$。sigmoid在[-1, 1]这段区间内近乎是一个线性函数,为了利用该激活函数的非线性区域,我们应该让$z$的取值范围更大一点,即让$z$的方差大于1。

这里的$\beta$和梯度下降算法里的$\beta$不是同一回事,只是这几个算法的原论文里都使用了$\beta$这个符号。

使用批归一化后,原来的神经网络计算公式需要做出一些调整。之前,$z^{(i)}$的计算公式如下:

现在,我们会把$z^{(i)}$的均值归一化到0。因此,$+b$成为了一个冗余的操作。使用了批归一化后,$z^{(i)}$应该按下面的方法计算:

总结一下,加入批归一化后,神经网络的计算过程如下所示:

注意,使用向量化计算后,$\tilde{Z}^{[l]}$的计算公式应该如下:

其中$\gamma^{[l]}$和$\beta^{[l]}$的形状都是$(n^{[l]}, 1)$

课堂里没有介绍批归一化的求导公式。这里补充一下:

使用批归一化后,常见优化算法(mini-batch, momentum, adam, …)仍能照常使用。

直观理解批归一化的作用

对于神经网络中较深的层,它们只能“看到”来自上一层的激活输出,而不知道较浅的层的存在。如下图所示,对于第3层,它只知道第2层的激活输出$A^{[2]}$。

这样,经过一段时间的训练后,网络的第3层和第4层知道了如何较好地把$A^{[2]}$映射成$\hat{y}$。

可是,$A^{[2]}$并不是神经网络的真实输入。神经网络真正的结构如下:

$A^{[2]}$其实还受到神经网络前2层参数的影响。一旦前2层的参数更新,$A^{[2]}$的分布也会随之改变,第3层和第4层可能要从头学习$A^{[2]}$到$\hat{y}$的映射关系。

与之相比,使用了批归一化后,神经网络每一层的输出都会落在一个类似的分布里。这样,浅层和深层之间就没有那么强的依赖关系,较深的层能够更快完成学习。

顺带一提,当我们用批归一化的同时,如果还使用了mini-batch,则批归一化还能稍微起到一点正则化的作用。这是因为在mini-batch上每层批归一化用到的方差和均值是不准确的,这种“带噪音”的批归一化能够起到和dropout类似的作用,防止神经网络以较大的权重依赖于少数神经元。

测试时的批归一化

我们刚刚学习的批归一化操作,其实都是针对训练而言的。在训练时,我们有大批的数据,可以轻松算出每一层中间结果$Z^{[l]}$的均值和方差。但是,在测试时,我们可能只会对一项输入进行计算。对一项输入计算均值和方差是没有意义的。因此,我们要想办法决定测试时$Z^{[l]}\to Z_{norm}^{[l]}$用到的均值和方差。

我们可以用每一个mini-batch的均值和方差的指数加权移动平均数作为测试时的均值和方差。

Softmax 与多分类问题

之前我们一直都在讨论二分类问题。比如,辨别一张图片是不是小猫。当我们把二分类问题拓展到多分类问题时,问题的数学模型会发生哪些变化呢?

首先,我们来看一下多分类问题的定义。在多分类问题中,我们要要判断一个输入是属于$C$种类型中的哪一种。比如我们希望判断一张图片里的生物是属于小猫、小狗、小鸡、其他这$C=4$类中的哪一种。

在二分类问题中,我们用1表示“是某一类”,0表示“不是某一类”。我们只需要计算$P(\hat{y}=1|x)$这一个概率。而多分类问题中,我们用一个数字表示一种类别,比如0表示“其他”,1表示“小猫”,2表示“小狗”,3表示“小鸡”。这样,我们就应该计算多个概率,比如$P(\hat{y}=0|x), P(\hat{y}=1|x), P(\hat{y}=2|x), P(\hat{y}=3|x)$这四个概率。

多分类问题的示意图和一个可能的多分类神经网络如下图所示(注意,该网络有4个输出):

接着,我们来看看多分类问题带来了哪些新的困难。在二分类问题中,我们得到了最后一层的计算结果$z^{[L]}$,我们要用sigmoid把它映射到表示概率的[0, 1]上。而多分类问题中,同理,我们要把神经网络最后一层的计算结果$z^{[L]}$映射成一些有实际意义的概率值。具体而言,我们应让所有分类概率之和为1,即$\Sigma_{i=1}^CP(\hat{y}=i|x)=1$。为了达到这个目的,我们要引入一个激活函数——softmax。

和其他定义在一个实数上的激活函数不同,softmax定义在一个向量上,其计算方式为:

注意,上式中所有运算都是逐元素运算。比如在上面提到的有四个类别的分类问题中,$z^{[L]}$是一个形状为$(4, 1)$的张量,经过逐元素运算后,$t, a^{[L]}$都是形状为$(4, 1)$的张量。

上述描述可能比较抽象,让我们看课件里的一个具体例子:

假设$z^{[L]}=[5, 2, -1, 3]$,则$t=[e^5, e^2, e^{-1}, e^3], \Sigma_{j=1}^4t_j\approx176.3,a^{[L]}_1\approx0.842,a^{[L]}_2\approx0.042,a^{[L]}_3\approx0.002,a^{[L]}_4\approx0.114$。

softmax的计算方法可以总结为:求指数,归一化。本质上来说,softmax就是把向量每个分量的自然指数作为一个新的标准,在这个标准上进行标准归一化操作。

为什么要使用向量每个分量的自然指数作为归一化的变量,而不直接对原向量做标准归一化呢?可以考虑[1, 2], [10, 20]这两个向量。如果直接对这两个量进行进行归一化,算出来的概率都是[0.33, 0.67]。而实际上,第一个向量可能对应一幅比较模糊的输入,第二个向量可能对应一幅比较清楚的输入。显然,在更清晰的输入上,我们更有把握说我们的分类结果是正确的。通过使用softmax,我们可以放大数值的影响,[10, 20]相比[1, 2],我们更有把握说输入是属于第二个类别的。该解释参考自https://stackoverflow.com/questions/17187507/why-use-softmax-as-opposed-to-standard-normalization

在C=2时,softmax会退化成sigmoid。也就是说,softmax是sigmoid在多分类任务上的推广。

softmax这个名字,其实衍生自hardmax这个词。使用hardmax时,输入会被映射成[1, 0, 0, 0]这样一个one-hot向量。这种最大值太严格(hard)了,所以有相对来说比较宽松(soft)的最大值计算方法softmax。

使用了softmax后,还需要调整的是网络的loss。推广到多分类后,我们要使用的loss是

,其中$y_i$不是一个表示类别的整数,而是一个one-hot编码的向量。比如在一共有4类时,标签2的one-hot编码是:

假设整个标签数据集为$[0, 1, 3, 2]$,则参与网络运算时用到的$Y$应该是:

在编程时,数据集一般只会提供用整数表示的标签。为了正确使用loss,我们需要多加一步转换到one-hot编码的步骤。

和逻辑回归类似,计算梯度时,$dZ^{[L]}=\hat{Y}-Y$这个等式依然成立,我们可以用它跳一个算梯度的步骤。

编程框架

由于深度学习的开发者越来越多,许多开源深度学习编程框架相继推出,比如:

  • Caffe/Caffe2
  • Torch
  • TensorFlow
  • Keras
  • mxnet
  • PaddlePaddle
  • CNTK
  • DL4J
  • Lasagne
  • Theano

这些编程框架不仅封装了常见的深度学习数学函数,如sigmoid、softmax、卷积,还支持自动求导的功能——这是深度学习编程框架最吸引人的一点。在使用编程框架时,我们只需要编写前向传播的过程,框架就会自动执行梯度计算,以辅助我们完成反向传播。

目前,学术界最常用的是Torch的Python版PyTorch。第二常用的是TensorFlow。

在选择编程框架时,我们要考虑以下几点:

  • 易用性(能否快速开发与部署)
  • 运行性能
  • 是否真正开源

前两点注意事项毋庸置疑。框架之于编程语言,就像高级语言之于汇编语言一样。我们选择编程框架而不去从零编程,最主要的原因就是开发效率。使用框架能够节约大量的开发时间,有助于项目的迭代。而使用统一的框架,往往会损失一些效率,这些损失的效率不能太多。

第三点要着重强调一下。很多框架打着开源的名号,实际上却是某个公司在维护。如果这个公司哪天不想维护了,放弃继续开源,那么你的开发就会受到很大的影响。

这周的课还介绍了TensorFlow的用法,我会在编程实战中补充这方面的知识。

总结

这堂课的知识点有:

  • 调参
    • 优先级
    • 采样策略
    • 搜索尺度
  • 批归一化
    • 在网络中的位置
    • 作用(归一化、新分布
    • 超参数与公式
    • 测试时的处理方式
  • 多分类问题
    • softmax
    • loss
  • 编程框架
    • 了解常见的编程框架
    • 选择编程框架的角度

通过这三周的学习,我们掌握了深度学习各方面的知识,能够用多种方式提升我们深度学习项目的性能了。

这周的编程要用到TensorFlow。我将另开一篇文章介绍本周的代码实战项目。

学习提示

一直以来,我们都用梯度下降法作为神经网络的优化算法。但是,这个优化算法还有很多的改进空间。这周,我们将学习一些更高级的优化技术,希望能够从各个方面改进普通的梯度下降算法。

我们要学习的改进技术有三大项:分批梯度下降、高级更新方法、学习率衰减。这三项是平行的,可以同时使用。

分批梯度下降是从数据集的角度改进梯度下降。我们没必要等遍历完了整个数据集后再进行参数更新,而是可以遍历完一小批数据后就进行更新。

高级更新方法指不使用参数的梯度值,而是使用一些和梯度相关的中间结果来更新参数。通过使用这些更高级的优化算法,我们能够令参数的更新更加平滑,更加容易收敛到最优值。这些高级的算法包括gradient descent with momentum, RMSProp, Adam。其中Adam是前两种算法的结合版,这是目前最流行的优化器之一。

学习率衰减指的是随着训练的进行,我们可以想办法减小学习率的值,从而减少参数的震荡,令参数更快地靠近最优值。

在这周的课里,我们要更关注每种优化算法的单独、组合使用方法,以及应该在什么场合用什么算法,最后再去关注算法的实现原理。对于多数技术,“会用”一般要优先于“会写”。

课堂笔记

分批梯度下降

这项技术的英文名称取得极其糟糕。之前我们使用的方法被称为”batch gradient descent”, 改进后的方法被称为”mini-batch gradient descent”。但是,这两种方法的本质区别是是否把整个数据集分成多个子集。因此,我们认为我的中文翻译“分批梯度下降”、“整批梯度下降”比原来的英文名词或者“小批量梯度下降”等中文翻译要更贴切名词本身的意思。

使用mini-batch

在之前的学习中,我们都是用整个训练集的平均梯度来更新模型参数的。而如果训练集特别大的话,遍历整个数据集要花很长时间,梯度下降的速度将十分缓慢。

其实,我们不一定要等遍历完了整个数据集再做梯度下降。相较于每次遍历完所有$m$个训练样本再更新,我们可以遍历完一小批次(mini-batch)的样本就更新。让我们来看课件里的一个例子:

假设整个数据集大小$m=5,000,000$。我们可以把数据集划分成5000个mini-batch,其中每一个batch包含1000个数据。做梯度下降时,我们每跑完一个batch里的1000个数据,就用它们的平均梯度去更新参数,再去跑下一个batch。

这里要介绍一个新的标记。设整个数据集$X$的形状是$(n_x, m)(m=5,000,000)$,则第$i$个数据集的标记为 $X^{\lbrace i \rbrace}$ ,形状为$(n_x, 1000)$。

再次总结一下标记:$x^{(i)[j]\lbrace k\rbrace}$中的上标分别表示和第i个样本相关、和第j层相关、和第k个批次的样本集相关。实际上这三个标记几乎不会同时出现。

使用了分批梯度下降后,算法的写法由

1
2
for i in range(m):
update parameters

变成

1
2
3
for i in range(m / batch_size)
for j in range(batch_size):
update parameters

。现在的梯度下降法每进行一次内层循环,就更新一次参数。我们还是把一次内层循环称为一个”step(步)”。此外,我们把一次外层循环称为一个”epoch(直译为’时代’,简称‘代’)”,因为每完成一次外层循环就意味着训练集被遍历了一次。

mini-batch 的损失函数变化趋势

使用分批梯度下降后,损失函数的变化趋势会有所不同:

如图所示,如果是使用整批梯度下降,则损失函数会一直下降。但是,使用分批梯度下降后,损失函数可能会时升时降,但总体趋势保持下降。

这种现象主要是因为之前我们计算的是整个训练集的损失函数,而现在计算的是每个mini-batch的损失函数。每个mini-batch的损失函数时高时低,可以理解为:某批数据比较简单,损失函数较低;另一批数据难度较大,损失函数较大。

选择批次大小

批次大小(batch size)对训练速度有很大的影响。

如果批次过大,甚至极端情况下batch_size=m,那么这等价于整批梯度下降。我们刚刚也学过了,如果数据集过大,整批梯度下降是很慢的。

如果批次过小,甚至小到batch_size=1(这种梯度下降法有一个特别的名字:随机梯度下降(Stochastic Gradient Descent)),那么这种计算方法又会失去向量化计算带来的加速效果。

回想一下第二周的内容:向量化计算指的是一次对多个数据做加法、乘法等运算。这种计算方式比用循环对每个数据做计算要快。

出于折中的考虑,我们一般会选用一个介于1-m之间的数作为批次大小。

如果数据集过小(m<2000),那就没必要使用分批梯度下降,直接拿整个数据集做整批梯度下降即可。

如果数据集再大一点,就可以考虑使用64, 128, 256, 512这些数作为batch_size。这几个数都是2的次幂。由于电脑的硬件容量经常和2的次幂相关,把batch_size恰好设成2的次幂往往能提速。

当然,刚刚也讲了,使用较大batch_size的一个目的是充分利用向量化计算。而向量化计算要求参与运算的数据全部在CPU/GPU内存上。如果设备的内存不够,则设过大的batch_size也没有意义。

一段数据的平均值

在课堂上,这段内容是从数学的角度切入介绍的。我认为这种介绍方式比较突兀。我将从计算机科学的角度切入,用更好理解的方式介绍“指数加权移动平均”。

背景

假设我们绘制了某年每日气温的散点图:

假如让你来描述全年气温的趋势,你会怎么描述呢?

作为人类,我们肯定会说:“这一年里,冬天的气温较低。随后气温逐渐升高,在夏天来到最高值。夏天过后,气温又逐渐下降,直至冬天的最低值。”

但是,要让计算机看懂天气的变化趋势,应该怎么办呢?直接拿相邻的天气的差作为趋势可不行。冬天也会出现第二天气温突然升高的情况,夏天也会出现第二天气温突然降低的情况。我们需要一个能够概括一段时间内气温情况的指标。

移动平均数

一段时间里的值,其实就是几天内多个值的总体情况。多个值的总体情况,可以用平均数表示。严谨地来说,假如这一年有365天,我们用$t$表示这一年每天的天气,那么:

我们可以定义一种叫做移动平均数(Moving Averages) 的指标,表示某天及其前几天温度的平均值。比如对于5天移动平均数$ma$,其定义如下:

假如要让计算机依次输出每天的移动平均数,该怎么编写算法呢?我们来看几个移动平均数的例子:

通过观察,我们可以发现$ma_6=ma_5+(t_6-t_1)/5$,$ma_7=ma_6+(t_7-t_2)/5$。

也就是说,在算n天里的m天移动平均数(我们刚刚计算的是5天移动平均数)时,我们不用在n次的外层循环里再写一个m次的循环,只需要根据前一天的移动平均数,减一个值加一个值即可。这种依次输出移动平均数的算法如下:

1
2
3
4
5
6
7
8
9
10
11
input temperature[0:n]
input m

def get_temperature(i):
return temperature[i] if i >= 0 and i < n else 0

ma = 0
for i in range(n):
ma += (get_temperature(i) - get_temperature(i - m)) / m
ma_i = ma
output ma_i

这种求移动平均数的方法确实很高效。但是,我们上面这个算法是基于所有温度值一次性给出的情况。假如我们正在算今年每天温度的移动平均数,每天的温度是一天一天给出的,而不是一次性给出的,上面的算法应该怎么修改呢?让我们来看修改后的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
input m
temp_i_day_ago = zeros((m))

def update_temperature(t):
for i in range(m - 1):
temp_i_day_ago[i+1] = temp_i_day_ago[i]
temp_i_day_ago[0] = t

ma = 0
for i in range(n):
input t_i
update_temperature(t_i)
ma += (temp_i_day_ago[0] - temp_i_day_ago[m]) / m
ma_i = ma
output ma_i

由于我们不能提前知道每天的天气,我们需要一个大小为m的数组temp_i_day_ago记录前几天的天气,以计算m天移动平均数。

上述代码的时间复杂度还是有优化空间的。可以用更好的写法去掉update_temperature里的循环,把计算每天移动平均数的时间复杂度变为$O(1)$。但是,这份代码的空间复杂度是无法优化的。为了算m天移动平均数,我们必须要维护一个长度为m的数组,空间复杂度一定是$O(m)$。

对于一个变量的m移动平均数,$O(m)$的空间复杂度还算不大。但假如我们要同时维护l个变量的m移动平均数,整个算法的空间复杂度就是$O(ml)$。在l很大的情况下,m对空间的影响是很大的。哪怕m取5这种很小的数,也意味着要多花4倍的空间去存储额外的数据。空间复杂度里这多出来的这个$m$是不能接受的。

指数加权移动平均

作为移动平均数的替代,人们提出了指数加权移动平均数(Exponential Weighted Moving Average) 这种表示一段时期内数据平均值的指标。其计算公式为:

这个公式直观上的意义为:一段时间内的平均温度,等于上一段时间的平均温度与当日温度的加权和。

相比普通的移动平均数,指数平均数最大的好处就是减小了空间复杂度。在迭代更新这个新的移动平均数时,我们只需要维护一个当前平均数$v_i$,一个当前的温度$t_i$即可,空间复杂度为$O(1)$。

让我们进一步理解公式中的参数$\beta$。把公式展开可得:

从这个式子可以看出,之前数据的权重都在以$\beta$的速度指数衰减。根据$(1-\epsilon)^{\frac{1}{\epsilon}} \approx \frac{1}{e}$,并且我们可以认为一个数到了$\frac{1}{e}$就小到可以忽视了,那么指数平均数表示的就是$\frac{1}{1-\beta}$天内数据的平均情况。比如$\beta=0.9$表示的是10天内的平均数据,$\beta=0.99$表示的是100天内的平均数据。

偏差矫正

指数平均数存在一个问题。在刚刚初始化时,指数平均数的值可能不太正确,请看:

让我们把每一项前面的权重加起来。对于$v_1$,前面的权重和是$(1-\beta)$;对于$v_2$,前面的权重和是$(1-\beta)(\beta+1)$。显然,这两个权重和都不为1。而计算平均数时,我们希望所有数据的权重和为1,这样才能反映出数据的真实大小情况。这里出现了权重上的“偏差”。

为了矫正这个偏差,我们应该想办法把权重和矫正为1。观察刚才的算式可以发现,第$i$项的权重和如下:

根据等比数列求和公式,上式化简为:

为了令权重和为1,我们可以令每一项指数平均数都除以这个和,即用下面的式子计算矫正后的指数平均数$v_i’$:

但是,在实践中,由于这个和$1-\beta^i$收敛得很快,我们不会特地写代码做这个矫正。

Momentum

Gradient Descent with Momentum (使用动量的梯度下降) 是一种利用梯度的指数加权移动平均数更新参数的策略。在每次更新学习率时,我们不用本轮梯度的方向作为梯度下降的方向,而是用梯度的指数加权移动平均数作为梯度下降的方向。即对于每个参数,我们用下式做梯度下降:

也就是说,对于每个参数$p$,我们用它的指数平均值$v_{dp}$代替$dp$进行参数的更新。

使用梯度的平均值来更新有什么好处呢?让我们来看一个可视化的例子:

不使用 Momentum 的话,每次参数更新的方向可能变化幅度较大,如上图中的蓝线所示。而使用 Momentum 后,每次参数的更新方向都会在之前的方向上稍作修改,每次的更新方向会更加平缓一点,如上图的红线所示。这样,梯度下降算法可以更快地找到最低点。

在实现时,我们不用去使用偏差矫正。$\beta$取0.9在大多数情况下都适用,有余力的话这个参数也可以调一下。

RMSProp 和 Adam

课堂上并没有对RMSProp的原理做过多的介绍,我们只需要记住它的公式就行。我会在其他文章中介绍这几项技术的原理。

在一个神经网络中,不同的参数需要的更新幅度可能不一样。但是,在默认情况下,所有参数的更新幅度都是一样的(即学习率)。为了平衡各个参数的更新幅度,RMSProp(Root Mean Squared Propagation) 在参数更新公式中添加了一个和参数大小相关的权重$S$。与 Momentum 类似,RMSProp使用了某种移动平均值来平滑这个权重的更新。其梯度下降公式如下:

在编程实现时,我们应该给分母加一个极小值$\epsilon$,防止分母出现0。

Adam (Adaptive Moment Estimation) 是 Momentum 与 RMSProp 的结合版。为了使用Adam,我们要先计算 Momentum 和 RMSProp 的中间变量:

之后,根据前面的偏差矫正,获得这几个变量的矫正值:

如前文所述,在实现时添加偏差矫正意义不大。估计这里加上偏差矫正是因为原论文加了。

最后,进行参数的更新:

和之前一样,这里的$\epsilon$是一个极小值。在编程时添加$\epsilon$,一般都是为了防止分母中出现0。

Adam是目前非常流行的优化算法,它的表现通常都很优秀。为了用好这个优化算法,我们要知道它的超参数该怎么调。在原论文中,这个算法的超参数取值如下:

绝大多数情况下,我们不用手动调这三个超参数。

学习率衰减

训练时的学习率不应该是一成不变的。在优化刚开始时,参数离最优值还差很远,选较大的学习率能加快学习速度。但是,经过了一段时间的学习后,参数离最优值已经比较近了。这时,较大的学习率可能会让参数错过最优值。因此,在训练一段时间后,减小学习率往往能够加快网络的收敛速度。这种训练一段时间后减小学习率的方法叫做学习率衰减

其实学习率衰减只是一种比较宏观的训练策略,并没有绝对正确的学习率衰减方法。我们可以设置初始学习率$\alpha_0$,之后按下面的公式进行学习率衰减:

这个公式非常简单,初始学习率会随着一个衰减率(DecayRate)和训练次数(EpochNum)衰减。

同样,我们还可以使用指数衰减:

或者其他一些奇奇怪怪的衰减方法(k是超参数):

甚至我们可以手动调学习率,每训练一段时间就把学习率调整成一个更小的常数。

总之,学习率衰减是一条启发性的规则。我们可以有意识地在训练中后期调小学习率。

局部最优值

在执行梯度下降算法时,局部最优值可能会影响算法的表现:在局部最优值处,各个参数的导数都是0。梯度是0(所有导数为0),意味着梯度下降法将不再更新了。

在待优化参数较少时,陷入局部最优值是一种比较常见的情况。而对于参数量巨大的深度学习项目来说,整个模型陷入局部最优值是一个几乎不可能发生的事情。某参数在梯度为0时,既有可能是局部最优值,也可能是局部最差值。不妨设两种情况的概率都是0.5。如果整个模型都陷入了局部最优值,那么所有参数都得处于局部最优值上。假设我们的深度学习模型有10000个参数,则一个梯度为0的点是局部最优值的概率是$0.5^{10000}$,这是一个几乎不可能发生的事件。

所以,在深度学习中,更常见的梯度为0的点是鞍点(某处梯度为0,但不是局部最值)。在鞍点处,有很多参数都处于局部最差值上,只要稍微对这些参数做一些扰动,参数就会往更小的方向移动。因此,鞍点不会对学习算法产生影响。

在深度学习中,一种会影响学习速度的情况叫做“高原”(plateau)。在高原处,梯度的值一直都很小。只有跨过了这段区域,学习的速度才会快起来。这种情况的可视化结果如下:

总而言之,深度学习问题和简单的优化问题不太一样,不用过多担心局部最优值的问题。而高原现象确实会影响学习的速度。

总结

这周,我们围绕深度学习的优化算法,学习了许多提升梯度下降法性能的技术。让我们来捋一捋。

首先,我们可以在处理完一小批数据后就执行梯度下降,而不必等处理完整个数据集后再执行。这种算法叫分批梯度下降(mini-batch gradient descent)。这是一种对梯度下降法的通用改进方法,即默认情况下,这种算法都可以和其他改进方法同时使用。

之后,我们学习了移动平均的概念,知道移动平均值可以更平滑地反映数据在一段时间内的趋势。基于移动平均值,有 gradient descent with momentum 和 RMSProp 这两种梯度下降的改进方法。而现在非常常用的 Adam 优化算法是Momentum 和 RMSProp 的结合版。

最后,我们学习了学习率衰减的一些常见方法。

学完本课的内容后,我认为我们应该对相关知识达到下面的掌握程度:

  • 分批梯度下降
    • 了解原理
    • 掌握如何选取合适的 batch size
  • 高级优化算法
    • 了解移动平均数的思想
    • 了解 Adam 的公式
    • 记住 Adam 超参数的常见取值
    • 未来学习了编程框架后,会调用 Momentum,Adam 优化器
  • 学习率衰减
    • 掌握“学习率衰减能加速收敛”这一概念
    • 在训练自己的模型时,能够有意识地去调小学习率
  • 局部最优值
    • 不用管这个问题

代码实战

这周,官方的编程作业还是点集分类。我觉得这个任务太简单了,还是挑战小猫分类比较有意思。

在这周的代码实战项目中,让我们先回顾一下整个项目的框架,再实现这周学到的技术,包括分批梯度下降(Mini-batch Gradient Descent)、高级梯度下降算法(Mini-batch Gradient Descent)、学习率衰减。

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

小猫分类项目框架

数据集

和之前一样,我们即将使用一个 kaggle 上的猫狗分类数据集。我已经写好了读取数据的函数,该函数的定义如下:

1
2
3
4
5
6
def get_cat_set(
data_root: str,
img_shape: Tuple[int, int] = (224, 224),
train_size=1000,
test_size=200,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:

填入数据集根目录、图像Reszie后的大小、一半训练集的大小、一半测试集的大小,我们就能得到预处理后的train_X, train_Y, dev_X, dev_Y。其中,X的形状是(n_x, m), Y的形状是(1, m)n_x是图像的特征数,对于一个大小为(224, 224)的图像,n_x = 224*224*3。m是样本数量,如果train_size=1000,则m=2000

在之前的实战中,我的模型在训练集上的表现都十分糟糕,还没有用到“测试集”的机会。因此,我们之前那个“测试集”,既可以认为是开发集,也可以认为是测试集。从这周开始,出于严谨性的考虑,我准备把之前的“测试集”正式称作开发集(dev set)。

模型类

和之前一样,我们用BaseRegressionModel来表示一个最后一层使用sigmoid,loss用交叉熵的二分类模型基类。这个基类的定义如下:

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 get_grad_dict(self) -> Dict[str, np.ndarray]:
pass

@abc.abstractmethod
def save(self) -> Dict[str, np.ndarray]:
pass

@abc.abstractmethod
def load(self, state_dict: Dict[str, np.ndarray]):
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

在模型类中,和训练有关的主要有forward, backward, get_grad_dict这三个方法,分别表示前向传播、反向传播、梯度获取。

这里要对get_grad_dict做一个说明。之前我们都是直接在模型类里实现梯度下降的,但在这周学了新的优化算法后,这种编程方式就不太方便拓展了。因此,从这周开始,我们应该用一个BaseOptimizer类来表示各种梯度下降算法。模型通过get_grad_dict把梯度传给优化器。

除了和训练相关的方法外,模型类通过save, load来把数据存入/取自一个词典,通过loss, evaluate来获取一些模型评测指标。

BaseRegressionModel只是一个抽象基类。实际上,我在本项目使用的是第四周学习的深层神经网络(任意层数的全连接网络)DeepNetwork。只需要传入每一层神经元个数、每一层的激活函数,我们就能得到一个全连接分类网络:

1
2
3
4
class DeepNetwork(BaseRegressionModel):

def __init__(self, neuron_cnt: List[int], activation_func: List[str]):
...

在第四周代码的基础上,我修改了一下参数初始化的方法。由于隐藏层的激活函数都用的是ReLU,我打算默认使用 He Initialization:

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

除此之外,我没有在这个模型上添加其他高级功能。我也没有添加正则化。现在网络还处于欠拟合状态,等我有资格解决过拟合问题时再去考虑正则化。

优化器类

看完了模型类,接下来,我们来看一看这周要实现的优化器类。所有的优化器类都继承自基类BaseOptimizer

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

def __init__(
self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
lr_scheduler: Callable[[float, int], float] = const_lr) -> None:
self.param_dict = param_dict
self._epoch = 0
self._num_step = 0
self._learning_rate_zero = learning_rate
self._lr_scheduler = lr_scheduler

@property
def epoch(self) -> int:
return self._epoch

@property
def learning_rate(self) -> float:
return self._lr_scheduler(self._learning_rate_zero, self.epoch)

def increase_epoch(self):
self._epoch += 1

def save(self) -> Dict:
return {'epoch': self._epoch, 'num_step': self._num_step}

def load(self, state_dict: Dict):
self._epoch = state_dict['epoch']
self._num_step = state_dict['num_step']

def zero_grad(self):
for k in self.grad_dict:
self.grad_dict[k] = 0

def add_grad(self, grad_dict: Dict[str, np.ndarray]):
for k in self.grad_dict:
self.grad_dict[k] += grad_dict[k]

@abc.abstractmethod
def step(self):
pass

这个优化器基类实现了以下功能:

  • 维护当前的epochstep,以辅助其他参数的计算。
  • 维护当前的学习率,并通过使用_lr_scheduler的方式支持学习率衰减。
  • 定义了从词典中保存/读取优化器的方法save, load
  • 定义了维护的梯度的清空梯度方法zero_grad和新增梯度方法add_grad
  • 允许子类实现step方法,以使用不同策略更新参数。

在后续章节中,我会介绍该如何使用这个基类实现这周学过的优化算法。

模型训练

基于上述的BaseRegressionModelBaseOptimizer,我们可以写出下面的模型训练函数:

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
def train(model: BaseRegressionModel,
optimizer: BaseOptimizer,
X,
Y,
total_epoch,
batch_size,
model_name: str = 'model',
save_dir: str = 'work_dirs',
recover_from: Optional[str] = None,
print_interval: int = 100,
dev_X=None,
dev_Y=None):
if recover_from:
load_state_dict(model, optimizer, recover_from)

# Prepare mini_batch
...

for e in range(total_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
mini_batch_Y_hat = model.forward(mini_batch_X)
model.backward(mini_batch_Y)
optimizer.zero_grad()
optimizer.add_grad(model.get_grad_dict())
optimizer.step()

currrent_epoch = optimizer.epoch

if currrent_epoch % print_interval == 0:
# print loss
...

optimizer.increase_epoch()

save_state_dict(model, optimizer,
os.path.join(save_dir, f'{model_name}_latest.npz'))

训练之前,我们可以从模型文件recover_from里读取模型状态和优化器状态。读取数据是通过load_state_dict实现的:

1
2
3
4
5
def load_state_dict(model: BaseRegressionModel, optimizer: BaseOptimizer,
filename: str):
state_dict = np.load(filename)
model.load(state_dict['model'])
optimizer.load(state_dict['optimizer'])

在得到某一批训练数据X, Y后,我们可以用下面的代码执行一步梯度下降:

1
2
3
4
5
Y_hat = model.forward(X)
model.backward(Y)
optimizer.zero_grad()
optimizer.add_grad(model.get_grad_dict())
optimizer.step()

我们会先调用模型的前向传播forward和反向传播backward,令模型存下本轮的梯度。之后,我们重置优化器,把梯度从模型传到优化器,再调用优化器进行更新。

训练代码中,默认使用了mini-batch。我会在后续章节介绍mini-batch的具体实现方法。

完成了梯度的更新后,我们要维护当前的训练代数epoch。训练了几代后,我们可以评测模型在整个训练集和开发集上的性能指标。

1
2
3
4
5
6
7
currrent_epoch = optimizer.epoch

if currrent_epoch % print_interval == 0:
# print loss
...

optimizer.increase_epoch()

最后,模型训练结束后,我们要保存模型。保存模型是通过save_state_dict实现的:

1
2
3
4
def save_state_dict(model: BaseRegressionModel, optimizer: BaseOptimizer,
filename: str):
state_dict = {'model': model.save(), 'optimizer': optimizer.save()}
np.savez(filename, **state_dict)

如果你对np.savez函数不熟,欢迎回顾我在第四周代码实战中对其的介绍。

总之,基于我们定义的BaseRegressionModelBaseOptimizer,我们可以在初始化完这个两个类的对象后,调用train来完成模型的训练。

使用 Mini-batch

注意 I/O 开销!

重申一下,Mini-batch gradient descent 的本意是加快训练速度。如果实现了 Mini-batch 后,程序在其他地方跑得更慢了,那么使用这个算法就毫无意义了。

在我们这个小型的深度学习项目中,从硬盘上读取数据的开销是极大的。下图是执行包含前后处理在内的一轮训练的时间开销分布:

从图中可以看出,相对于一轮训练,读取数据的开销是极大的。读取数据的时间甚至约等于两轮训练的时间。

在之前的项目中,我一直默认是把训练数据全部读取到内存中,然后再进行训练。这样的好处是网络的训练速度不受硬盘读写速度限制,会加快不少,坏处是训练数据的总量受到电脑内存的限制。

在使用分批梯度下降算法时,为了比较算法在性能上的提升,我们应该继续使用相同的数据管理策略,即把数据放到内存中处理。如果换了算法,还换了数据管理策略,把一次性读取数据改成每次需要数据的时候再去读取,那么我们就无法观察到算法对于性能的提升。

事实上,在大型深度学习项目中,模型执行一轮训练的速度很慢,I/O的开销相对来说会小很多。在这种时候,我们可以仅在需要时再读取数据。不过,在这种情况下,我们依然要保证内存/显存足够支持一轮mini-batch的前向/反向传播。这里要注意一下我们这个小demo和实际深度学习项目的区别。

mini-batch 预处理

在执行一个epoch(代)的训练时,我们应该保证训练数据是打乱的,以避免极端数据分布给训练带来的副作用。

epoch 与 epoch 之间 mini-batch 的划分是否相同到不是那么重要。理论上来说,数据越平均越好,最好能每个 epoch 都重新划分 mini-batch。但是,为了加速训练,同时让使用 mini-batch 的逻辑更加易懂,我打算先预处理出 mini-batch,之后每个 epoch 都使用相同的划分。

为了方便之后的处理,我们把每个mini-batch的X和Y都单独存入数组mini_batch_XYs。这样,在之后的训练循环里,每个mini-batch的数据就可以直接拿来用了。以下是预处理mini-batch的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
m = X.shape[1]
indices = np.random.permutation(m)
shuffle_X = X[:, indices]
shuffle_Y = Y[:, indices]
num_mini_batch = math.ceil(m / batch_size)
mini_batch_XYs = []
for i in range(num_mini_batch):
if i == num_mini_batch - 1:
mini_batch_X = shuffle_X[:, i * batch_size:]
mini_batch_Y = shuffle_Y[:, i * batch_size:]
else:
mini_batch_X = shuffle_X[:, i * batch_size:(i + 1) * batch_size]
mini_batch_Y = shuffle_Y[:, i * batch_size:(i + 1) * batch_size]
mini_batch_XYs.append((mini_batch_X, mini_batch_Y))

在这段代码中,我们首先用第二周编程练习中学过的permutation生成一个随机排列,并根据这个随机排列打乱数据。

之后的代码就是一段常见的数据除法分块逻辑。对于除得尽和除不尽的mini-batch,我们分开处理,提取出每个mini_batch的X和Y。

mini-batch 训练

预处理得当的话,用mini-batch进行训练的代码非常简洁。我们只需要在原来的训练循环里加一个对mini-batch的遍历即可:

1
2
3
4
5
for e in range(num_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
mini_batch_Y_hat = model.forward(mini_batch_X)
model.backward(mini_batch_Y)
model.gradient_descent(learning_rate)

mini-batch 的损失函数曲线

和我们在课堂里学的一样,使用mini-batch后,损失函数的曲线可能不像之前那么平滑。这是因为我们画损失函数曲线时用的是每个mini-batch上的损失函数,而不是整个训练集的损失函数。我得到的一个mini-batch损失函数曲线如下:

在训练时,我顺手存了一下每个mini-batch的梯度,并在训练结束后对它们进行可视化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mini_batch_loss_list = []
for e in range(num_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
...

if plot_mini_batch:
loss = model.loss(mini_batch_Y, mini_batch_Y_hat)
mini_batch_loss_list.append(loss)
if plot_mini_batch:
plot_length = len(mini_batch_loss_list)
plot_x = np.linspace(0, plot_length, plot_length)
plot_y = np.array(mini_batch_loss_list)
plt.plot(plot_x, plot_y)
plt.show()

实现高级优化算法

有了基类BaseOptimizer后,我们只需要实现子类的构造函数和更新函数,就可以实现各种各样的改进梯度下降算法了。让我们看一下这周学习的Momentum, RMSProp, Adam该如何实现。

Momentum

Momentum的主要实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Momentum(BaseOptimizer):

def __init__(self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
beta: float = 0.9,
from_scratch=False) -> None:
super().__init__(param_dict, learning_rate)
self.beta = beta
self.grad_dict = deepcopy(self.param_dict)
if from_scratch:
self.velocity_dict = deepcopy(self.param_dict)
for k in self.velocity_dict:
self.velocity_dict[k] = 0

def step(self):
self._num_step += 1
for k in self.param_dict:
self.velocity_dict[k] = self.beta * self.velocity_dict[k] + \
(1 - self.beta) * self.grad_dict[k]
self.param_dict[k] -= self.learning_rate * self.velocity_dict[k]

在Momentum中,我们主要是维护velocity_dict这个变量。根据课堂里学过的知识,这个变量的值等于梯度的指数移动平均值。因此,我们只需要在step里维护一个指数平均数即可。

为了保存优化器的状态,我们应该在save, load里保存velocity_dict

1
2
3
4
5
6
7
8
9
10
11
12
def save(self) -> Dict:
state_dict = super().save()
state_dict['velocity_dict'] = self.velocity_dict
return state_dict

def load(self, state_dict: Dict):
self.velocity_dict = state_dict.get('velocity_dict', None)
if self.velocity_dict is None:
self.velocity_dict = deepcopy(self.param_dict)
for k in self.velocity_dict:
self.velocity_dict[k] = 0
super().load(state_dict)

RMSProp

RMSProp的主要实现代码如下:

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
class RMSProp(BaseOptimizer):

def __init__(self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
beta: float = 0.9,
eps: float = 1e-6,
from_scratch=False,
correct_param=True) -> None:
super().__init__(param_dict, learning_rate)
self.beta = beta
self.eps = eps
self.grad_dict = deepcopy(self.param_dict)
self.correct_param = correct_param
if from_scratch:
self.s_dict = deepcopy(self.param_dict)
for k in self.s_dict:
self.s_dict[k] = 0

def step(self):
self._num_step += 1
for k in self.param_dict:
self.s_dict[k] = self.beta * self.s_dict[k] + \
(1 - self.beta) * np.square(self.grad_dict[k])
if self.correct_param:
s = self.s_dict[k] / (1 - self.beta**self._num_step)
else:
s = self.s_dict[k]
self.param_dict[k] -= self.learning_rate * self.grad_dict[k] / (
np.sqrt(s + self.eps))

和Momentum类似,我们要维护一个指数平均数权重s_dict,并在更新参数时算上这个权重。由于RMSProp是除法运算,为了防止偶尔出现的除以0现象,我们要在分母里加一个极小值eps

我在这个优化器中加入了偏差校准功能。如果开启了校准,指数平均数会除以一个(1 - self.beta**self._num_step)

类似地,RMSProp中也用save, load来保存状态s_dict

1
2
3
4
5
6
7
8
9
10
11
12
def save(self) -> Dict:
state_dict = super().save()
state_dict['s_dict'] = self.s_dict
return state_dict

def load(self, state_dict: Dict):
self.s_dict = state_dict.get('s_dict', None)
if self.s_dict is None:
self.s_dict = deepcopy(self.param_dict)
for k in self.s_dict:
self.s_dict[k] = 0
super().load(state_dict)

注意,RMSProp实际上是对学习率进行了一个放缩。在把模型的优化算法从Momentum改成RMSProp后,学习率要从头调整。一般来说,RMSProp里的权重s_dict是一个小于1的数。这个数做了分母,等价于放大了学习率。因此,使用RMSProp后,可以先尝试把学习率调小100倍左右,再做进一步的调整。

Adam

Adam 的主要实现代码如下:

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
class Adam(BaseOptimizer):

def __init__(self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
beta1: float = 0.9,
beta2: float = 0.999,
eps: float = 1e-8,
from_scratch=False,
correct_param=True) -> None:
super().__init__(param_dict, learning_rate)
self.beta1 = beta1
self.beta2 = beta2
self.eps = eps
self.grad_dict = deepcopy(self.param_dict)
self.correct_param = correct_param
if from_scratch:
self.v_dict = deepcopy(self.param_dict)
self.s_dict = deepcopy(self.param_dict)
for k in self.v_dict:
self.v_dict[k] = 0
self.s_dict[k] = 0

def step(self):
self._num_step += 1
for k in self.param_dict:
self.v_dict[k] = self.beta1 * self.v_dict[k] + \
(1 - self.beta1) * self.grad_dict[k]
self.s_dict[k] = self.beta2 * self.s_dict[k] + \
(1 - self.beta2) * (self.grad_dict[k] ** 2)
if self.correct_param:
v = self.v_dict[k] / (1 - self.beta1**self._num_step)
s = self.s_dict[k] / (1 - self.beta2**self._num_step)
else:
v = self.v_dict[k]
s = self.s_dict[k]
self.param_dict[k] -= self.learning_rate * v / (np.sqrt(s) +
self.eps)

Adam 就是把 Momentum 和 RMSProp 结合一下。在Adam中,我们维护v_dicts_dict两个变量,并根据公式利用这两个变量更新参数。

这里有一个小细节:在Adam中,eps是写在根号外的,而RMSProp中eps是在根号里面的。这是为了与原论文统一。其实eps写哪都差不多,只要不让分母为0即可。

类似地,Adam要在状态词典里保存两个变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def save(self) -> Dict:
state_dict = super().save()
state_dict['v_dict'] = self.v_dict
state_dict['s_dict'] = self.s_dict
return state_dict

def load(self, state_dict: Dict):
self.v_dict = state_dict.get('v_dict', None)
self.s_dict = state_dict.get('s_dict', None)
if self.v_dict is None:
self.v_dict = deepcopy(self.param_dict)
for k in self.v_dict:
self.v_dict[k] = 0
if self.s_dict is None:
self.s_dict = deepcopy(self.param_dict)
for k in self.s_dict:
self.s_dict[k] = 0
super().load(state_dict)

Adam使用的学习率和RMSProp差不多。如果有一个在RMSProp上调好的学习率,可以直接从那个学习率开始调。

学习率衰减

要实现学习率衰减非常容易,我们只需要用一个实时计算学习率的学习率getter来代替静态的学习率即可。在BaseOptimizer中,我们可以这样实现学习率衰减:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BaseOptimizer(metaclass=abc.ABCMeta):

def __init__(
self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
lr_scheduler: Callable[[float, int], float] = const_lr) -> None:
self.param_dict = param_dict
self._epoch = 0
self._num_step = 0
self._learning_rate_zero = learning_rate
self._lr_scheduler = lr_scheduler

@property
def learning_rate(self) -> float:
return self._lr_scheduler(self._learning_rate_zero, self.epoch)

BaseOptimizer类中,我们用@property装饰器装饰一个learning_rate方法,以实现一个getter函数。这样,我们在获取optimizer.learning_rate这个属性时,实际上是在调用learning_rate这个函数。

getter中,我们用_lr_scheduler来实时计算一个学习率。_lr_scheduler是一个函数,该函数应该接受初始学习率、当前的epoch这两个变量,返回一个当前学习率。通过修改这个_lr_scheduler,我们就能使用不同的学习率衰减算法。

在代码中,我只实现了两个简单的学习率衰减函数。首先是常数学习率:

1
2
def const_lr(learning_rate_zero: float, epoch: int) -> float:
return learning_rate_zero

之后是课堂上学过的双曲线衰减函数:

1
2
3
4
5
6
def get_hyperbola_func(decay_rate: float) -> Callable[[float, int], float]:

def scheduler(learning_rate_zero: float, epoch: int):
return learning_rate_zero / (1 + epoch * decay_rate)

return scheduler

get_hyperbola_func是一个返回函数的函数。我们可以用get_hyperbola_func(decay_rate)生成一个某衰减率的学习率衰减函数。

实验结果

经实验,高级优化技术确实令训练速度有显著的提升。为了比较不同优化技术的性能,我使用2000个小猫分类样本作为训练集,使用了下图所示的全连接网络,比较了不同batch size不同优化算法不同学习率衰减方法下整个数据集的损失函数变化趋势。

以下是实验的结果:

首先,我比较了不同batch size下的mini-batch梯度下降。

从理论上来看,对于同一个数据集,执行相同的epoch,batch size越小,执行优化的次数越多,优化的效果越好。但是,batch size越小,执行一个epoch花的时间就越多。batch size过小的话,计算单元的向量化计算无法得到充分利用,算法的优化效率(单位时间内的优化量)反而下降了。

上面的实验结果和理论一致。执行相同的epoch,batch size越小,优化的效果越好。同时,batch size越小,误差也更容易出现震荡。虽然看上去batch size越小效果就越好,但由于向量化计算的原因,batch size为64,128,2000时跑一个epoch都差不多快,batch size为8时跑一个epoch就很慢了。我还尝试了batch size为1的随机梯度下降,算法跑一个epoch的速度奇慢无比,程序运行效率极低。最终,我把64作为所有优化算法的batch size。

之后,我比较了普通梯度下降、Momentum、RMSProp、Adam的优化结果。在普通梯度下降和Momentum中,我的学习率为1e-3;在RMSProp和Adam中,我的学习率为1e-5。

由于不同算法的学习率“尺度”不一样,因此,应该去比较普通梯度下降和Momentum,RMSProp和Adam这两组学习率尺度一样的实验。

对比普通梯度下降和Momentum,可以看出Momentum能够显著地提升梯度下降的性能,并且让误差的变化更加平滑。

对比RMSProp和Adam,可以看出学习率相同且偏小的情况下,Adam优于RMSProp。

感觉Adam的性能还是最优秀的。如果把Adam的学习率再调一调,优化效果应该能够超过其他算法。

最后,我还尝试了三个学习率衰减策略实验。每次实验都使用Adam优化器,初始学习率都是1e-5。第一次实验固定学习率,之后的两次实验分别使用衰减系数0.2,0.005的双曲线衰减公式。以下是实验结果:

从图中可以看出,由于初始学习率较低,在使用了比较大的衰减系数(=0.2)时,虽然学习的过程很平滑,但是学习速度较慢。而如果使用了恰当的衰减系数,虽然学习率在缓缓降低,但学习的步伐可能更加恰当,学习的速度反而变快了。

不过,RMSProp本身就自带调度学习率的效果。主动使用学习率衰减的效果可能没有那么明显。相比mini-batch和高级优化算法,学习率衰减确实只能算是一种可选的策略。

感想

我的实验还做得不是很充分。理论上可以再调一调学习率,更加公平地比较不同的学习算法。但是,我已经没有动力去进一步优化超参数了——由于目前学习算法的性能过于优秀,模型已经在训练集上过拟合了,训练准确率达到了80%多,远大于58%的开发准确率。因此,根据上一周学的知识,我的下一步任务不是继续降低训练误差,而是应该使用正则化方法或者其他手段,提高模型的泛化能力。在后续的课程中,我们还会接着学习改进深度学习项目的方法,届时我将继续改进这个小猫分类模型。

其实,过拟合对我来说是一件可喜可贺的事情。前两周,仅使用普通梯度下降时,模型的训练准确率和测试准确率都很低,我还在怀疑是不是我的代码写错了。现在看来,这完全是梯度下降算法的锅。朴素的梯度下降算法的性能实在是太差了。稍微使用了mini-batch、高级优化算法等技术后,模型的训练速度就能有一个质的飞跃。在深度学习项目中,mini-batch, Adam优化器应该成为优化算法的默认配置。