0%

经历

学校申请在2023年1月31日截止,托福成绩要求100分。

我在两年前读本科时考过托福,阅读/听力/口语/写作的分数分别为26/25/21/22,共94分。在这两年里,我的英语听说读写水平均有提升。大致估计一下,我应该可以轻轻松松地多考6分,拿到100+的成绩。

带着这样的想法,我2022年11月才开始准备托福。准备之前,我先订下了复习的策略。由于我不知道现在的考试水平,只能以两年前的成绩为基础,思考各部分的提分策略。阅读、听力都是客观题,很容易自学,各提个2分非常简单。据很多人反映,口语很难靠自学提升,那我只要保持之前的21分即可。这样,要让总分超过100,这次考试最重要的就是写作了。对于看重英语读写教育的中国学生来说,英语写作的基础在高中时就学得差不多了。而针对托福考试的写作技巧,则可以快速学会。因此,我应该花时间集中攻关托福写作技巧。综上,我的复习策略总结为:稳步提高阅读与听力,保持口语,攻关写作。

11月,我一边正常工作,一边断断续续地做了阅读和听力的TPO真题,再次熟悉了这两部分的应试方法。同时,我也做了一两套口语题,熟悉了题型,保证听完题目后能张口答题。最后一周,我开始集中学习写作技巧。网上的托福「教程」全是打广告的垃圾信息。我从互联网垃圾堆里翻出了一篇论坛上的经验分享贴,下载了一本叫做《十天突破新托福写作》的书。这本书虽然成书于2010年,但其内容十分全面,毫不过时。我认真学习了一些能够短期内掌握的写作技巧。

慢吞吞地准备了一个月,差不多了。我把第一场托福家考的时间订在12月5日早上9:50。我不方便把考试时间订在我最清醒的晚上,又考虑到我有以前线下考试的经验,才把时间安排在了早上。另外,12月5日是星期一,在此之前我能有一整个周末的时间来做最后的准备。如果一切顺利的话,我可以在不影响正常工作的前提下于周一下午结束托福考试。

第一场考试即将开始。我知道自己准备得不够好,却也因此做好了多考几次的打算,只把这次考试当成了一次分数测试,没有什么心理压力。托福家考的考官会对用作考场的个人房间进行检查。和考官的几句英文对话,也恰到好处地缓解了我的紧张,令我的大脑迅速进入了英语环境。克服了刚开始动脑时打的几个哈欠后,我顺顺利利地完成了考试。

和普通的线下托福机考一样,家考结束后,只有客观题的阅读和听力会立刻出分。我迫不及待地点下查分按钮后,看到的是一个令我傻眼的分数:阅读27,听力19。

我曾经为查分结果做出多种假设,以决定下一次考试的时间。如果阅读和听力的分数特别好,我就等所有分数出了再看要不要继续考试;如果阅读和听力的分数较差,我就再学一阵,学得差不多了就考。无论如何,看到分数后,我的心情要么是欣喜,要么是失落。可是,看到这只有19分的听力,我先是撇嘴笑了笑,随后皱起了眉头。一阵不服气的火焰从我心中燃起。种种迹象表明,我的英语听力水平没有那么差。在这次听力考试时,我每篇材料也听得津津有味。怎么能考出这样的分数出来呢?这已经不是我有问题了,这绝对是因为托福出题方ETS出题不力,不能让题目反映应试者水平。空口无凭,下一次,我会用我的无懈可击的表现来证明托福听力的不合理。

我也不管这次的分数怎么样了,准备在最快的时间里开始下一次考试。根据最新的规定,3天之后就能考下一场托福。但是,在临考一周内报名要交额外的费用。于是,我决定在不缴纳晚考费的第一天,也就是下周一的同一时间,再战托福。在这一周,我会把我的托福听力水平提升到最强的境界。考完了,如果听力考出高分,我还要写一篇文章来总结托福听力经验,把托福听力狠狠地踩在地上。接下来一周的剧本,已经准备就绪。

自我上进并不是令人奋斗的理由。只有对自我或外界的不满而产生的复仇心理,才能催促人不断前进。我的拖延被立刻治好。第一场考试结束当晚,我就精神饱满地重新开始了托福听力的准备。本来我想把这整周都用在听力的练习上。可是,由于学习速度过快,练了三天,我就发现我的听力水平大有提升,以至于没有什么进步空间了。于是,最后几天,我还是去找了找口语和写作的状态,为第二次考试准备。

12月12日,周一,早上9:50,为了完成对托福听力的复仇,我又杀回来了。由于是第二次参加托福家考,一切安检的流程我都轻车熟路。十分钟内,考官就检查完了资料和考试环境,第二场考试开始了。

第一部分是阅读。我平时做阅读总是会错几题,尤其是最后的六选三大题。但是,我对此毫不在意。因为我知道,平时我阅读出错,完全是因为我不认真,太浪。如果换成真正的考试,我就会拿出全部的实力,认真记忆文章,根本不会错那么多题。事实也确实如此。在第二次阅读考试中,我已经适应了考场上做阅读的策略,做题做得心应手。72分钟,4篇文章,我只需要每篇文章都花18分钟即可。每篇文章我都留3分钟来解决最后的六选三大题。把所有题做对不成问题。

之后是听力。我的状态和上周差不多,略有不同的是,我记笔记的方法更好了,信心更足了。口语和综合写作的发挥也和上周差不多。最后一项任务是独立写作,其话题稍微有点难度:「政府要建拆旧建筑修新建筑,一批人要被迫搬到新的地方,你同意吗?」我半天没有找到特别通畅的写作思路,只好硬着头皮分个人、企业、政府三个方面扯了一堆反对的理由。

考试结束,又到了开奖时间。确认考试结束的画面我看都不看,狂点着下一步,迫不及待地查看起阅读和听力的分数:

阅读28,听力27。

多么令人惊喜的分数!这两部分的分数均创我个人新高,成功达成了「阅读、听力各高2分」的目标。另外,我前一周吹的牛也成真了。我确实考出了一个不错的听力成绩,有资格去写托福听力准备攻略了。再次确认分数没有看错后,我在房间里大笑不止。计划的顺利执行、复仇的完美实践、成功的如期而至、心腹大患已除的如释重负。种种喜讯,充斥着我的大脑,让我只能以大笑来释放那高悬已久的紧张心情。我一边笑,一边狂妄地叫道:「如果这个阅读和听力的分数都不能让我总分上100,我就去炸了ETS!」我觉得这次总分肯定有100分了。把两年前的口语阅读分搬过来,就已经有98分了。这两部分只要稍微提高一点,100分就有了。

两天后的早上,也就是第一场考试结束后的第九天,ETS寄来邮件,说第一场考试的成绩已出。我一看,阅读27,听力19,口语23,写作26,总分95。两年前,我的口语和写作分别是21分和22分。这样一看,我口语和写作的提升确实不小。把昨天刚考出来的阅读和听力在拼起来,总分就有104分了。相比上周的发挥,我有足足4分的退步空间。我觉得我这次考试上100分已经是板上钉钉的事情了。

我兴冲冲地和领导讲,托福已经考完了,我从这周开始可以正常干活了。用于做笔记的白板被我扔到房间一角,为了通过考试安检而清理的房间又被我弄乱,羞于让考官看到的二次元电脑桌面又被我换回。生活重回正轨,仿佛什么都没有发生过。我写了一篇托福听力准备攻略,以此为送别托福的赠礼。

放松了一周,又到了周一早上。总算,我不用再面对9:50开始的托福考试,可以睡个懒觉,再去学校。本来是轻松快乐的一天,不知怎地,我怎么都提不起劲,仿佛是背后被锁链扯住了。到晚上临睡时,不安感越来越强。我这才想起来,明天是考试后第八天。最快的情况下,明天就会出分了。在最终分数出来之前,一切还没有结束。

周二早上9点,我在睡醒后立刻点开了邮箱。果然,上次的托福分数已经出了。我连忙点进查分页面,第一眼看到了一个三位数的分数。我按捺住激动的心情,仔细一看,这个分数是104分。原来这是TOEFL MyBest Score,也就是各个小分的最优拼分结果。和我算得一样,拼分结果是104分。现在庆祝还为时尚早,我把眼睛往上挪了挪,看到了我最不想见到的结果——一个只有两位数的总分。定睛一看,上次考试的总分是99分!口语、写作都只考了22分,这导致最终的总分刚好离及格线100分差1分。

反复确认学校不接受拼分结果后,我的心凉了下来。无数的负面想法萦绕在我的脑中:「考前怎么不多准备一下口语和写作」、「为什么这么晚才考试」、「考试的时候状态好一点就好了」……。但是,现在,连多余的自责都是没有意义的。我要做的,只有尽力考好下一次托福。我也没有思考,照着上次的习惯,报考了下周二早上9:50的托福家考。一切从头开始。

这一周,我完全失去了活力。每天上午,楼下都在轰轰隆隆地装修。我晚上本来就睡得晚,白天多躺一下的权利也失去了。生活中又突然碰到了一些事,我不得不花了一两天去处理。好不容易到了周末,我才有心思去准备一下托福。可是,我一看到屏幕上的做题界面,就感到全身有无数根线在撕扯着我,让我快点结束做题。

截至周一,考试前一天,我这周几乎没有花时间准备考试,生物钟也乱得一塌糊涂。我想,最后一天了,准备考试也来不及了,就把生物钟调整一下吧。我找事情消磨着时间,希望自己不要在白天补觉了。下午,吃过午饭,我实在困得受不了,就靠在床头,心想:「就靠着休息一下吧。靠着睡睡得不舒服,睡一下应该就能醒来。」但当我醒来的时候,天已经黑了。我已经困到连靠着睡也能睡四个小时了。根据前几天的经验,我知道今天晚上睡不着了。

睡不了觉,那就用最后的时间准备一下考试吧。按我以前的复习习惯,考试前最后准备的应该是口语。然而,听完一道口语的题面,花完15秒的准备时间,当录音开始的一瞬间,我发现自己张嘴发不出声音。原来,在准备时间里,我的大脑一片空白,根本组织不出英语句子。既然如此,开口讲一些乱七八糟的话,也是浪费时间。巨大的焦虑,把开口讲英语也变成了一件困难的事。

什么事都做不下去,我只好关灯,上床,躺着,一秒一秒迎接末日的来临。在最应该睡觉的夜晚,我的大脑却无比清醒。我糊里糊涂地想了很多事情。最后也想开了,这次考试肯定是考不过了。今天是12月27日,离1月31日还早,还有机会准备。我爬起来想去取消这次考试,挽回一些考试费,却发现考试早就定下来不允许取消了。没办法,就当这次考试是调整心情吧。相比白白花了钱不去考试,还是参加一下比较好。

早上七点,天亮了。我总算困了,睡了下去。曾经慢慢吞吞令我倍感煎熬的时钟,此刻却又卯足了劲飞奔起来。没过多久,闹钟就响了,提醒我9:50时有一件非做不可的事。9:30,我艰难地起了床,准备迎接第三次考试。

我从来没有喝醉过酒,此刻,我却能深刻理解喝醉酒的痛苦。听说酒喝多了会呕吐,那么,止不住的呕吐,应该和止不住的哈欠是一样的吧。明知困得睁不开眼,却要在不断的哈欠中强打精神。这种折磨不是和明知胃里没有东西却不得不重复地呕吐一样吗?每次早上考托福时,我其实睡得都不太够。做一开始的阅读题时,精神又必须得完全集中。所以,每次考托福,我都会打一阵子哈欠,花一些时间让大脑适应。而今天,我的睡眠时间实在太短了,以至于哈欠不断,大脑久久不能集中。还是4篇阅读,72分钟,平均每篇18分钟。第一篇阅读我恰好花18分钟做完。做第二篇时,我被一道题卡住了,多花了足足6分钟。后面两篇文章我只好平均花15分钟飞快地做完。做完阅读,我就知道这次考试已经没救了。随后,我又勉强做完了听力题。到了口语考试时,我糟糕的状态就一发不可收拾了。和前一天晚上的表现一样,我在口语的准备时间里非常慌张,根本动不了脑子。等开始录音了,我才开始边想边讲,凭借本能让英语单词一个一个从嘴里蹦出来。最后,到了写作的时候,我的脑子总算清醒了。结果,独立写作来了道题面很长,题材很怪的题。我根本想不出这个话题可以从哪三个角度来讲,只好硬凑字数,第一次写了一篇只有两个主体段的文章。

考试结束,我漠然地查看客观题成绩:阅读25,听力22。「还好,至少听力比第一次的19分高。」反正这次考试肯定考不过100,我只能苦中作乐,怎么乐观怎么想了。确认考试结束后,我如释重负,往床上猛地一躺。

差不多是上周的这个时候,我确认了托福还要再考。当时,我曾用一张薄纸封住了内心的负面想法,只希望下次考试能够尽力。虽然我无时无刻不在被透过纸的针扎痛,但我一直扛到了现在。如今,已经没有任何克制的需要了。「为什么不早点准备?」、「为什么不好好准备?」、「为什么会差1分?」、「为什么这周状态那么差」、「为什么……?」、「为什么……?」、「为什么……?」、……。新账加上旧账,弯刀接着利剑。无尽的责问,让疲惫的我想躺在床上就此昏睡过去。可是,一想到没能好好睡觉也是失利的败因之一,我又立刻咬牙坐了起来。疲倦与饥饿接踵而至,我决定先去吃饭。

吃完饭,我去超市里买生活用品。没想到,几天不来,超市从白与绿的圣诞风格,变成了以红为主的春节风格。我这才发现,圣诞节已经在不经意间过去了。再这样考不过,新年也是同样的过法吧。在这一个多月,我除了准备托福和消磨时间,别的什么都没干。学习停了,工作停了,什么都不会做了。我就在不断重复着考托福,考不过,考托福,考不过。这样下去,一月底还考不过,导师可能就不要我了。后面的事我也不敢再想下去了。

吃饱喝足后,我冷静分析了一下现状。我认为我的水平没有问题,只是这周的状态太差了。第二次考试相比第一次,写作的分数低了很多。我还是应该保持其他成绩,全力提升写作。离申请截止还有四周,最多最多还有三次考试的机会。我振作起来,又报名了一周后,也就是下个周二的考试。说来奇怪,一考完,我的精神状态就正常了,当天晚上也睡得很香。过了两天,楼下的装修声也听不到了,我的生物钟算是调整回去了。我名义上还是在正常上班,工作日的时候敲了敲代码,生活的自信又找回来了。

正常准备了一周,转眼又到了考试前一天。按惯例,最后的时间我还是在练口语。上周的事情让我心有余悸,这次一定不能怠慢口语了。可是,无论我怎么练,说起话来都磕磕巴巴的,找不回第一次考试时那种飞速组织语言、毫无顾虑地发言的状态了。我花了很多时间,才练到勉勉强强能把回答说完。

1月3日,第四次考试。我虽然睡得还不是很充足,但算是回到了那种打几个哈欠就能清醒的状态了。然而,这次又出现了一点意外情况。之前的考试,我怕考官会禁止我使用纸巾,鼻子不舒服也没有拿纸来擤鼻涕。这次考试,我的鼻子特别不舒服。我拿纸快速地擤了一下鼻涕,发现考官并没有提出异议。我的鼻子好像接收到了这个信号,开始止不住地流鼻涕。结果,我隔一两分钟就要擦一次鼻涕。本来就困,呼吸还不通畅,我阅读做到一半突然就开始手脚发麻。还好我脑子一直是清醒的,除了中间略有卡壳外,大部分阅读题我都能确定地答出。做到听力时,我的鼻子总算正常了,考试开始平稳进行。口语考试时,我的状态也和考前一样,非常一般,只是恰好能够把题目答完。由于之前做过足够的练习,我做综合写作倒是十分顺利。可是,做独立写作时,又不太对劲了。题面是「乡村生活和城市生活哪个更好」。我想出了三个城市好的理由,却没太想好怎么论证,写了几个不太自然的示例。

考完,又是要提前看分。经历丰富的我面对查分已经没有了任何激动,只是平淡地一步一步点进了查分界面。这次,阅读28,听力21。阅读的发挥是符合预期的,但这个听力是怎么回事?怎么多睡了几个小时还没有只睡两小时做的听力分数高?

经历了太多太多,我已经麻木了。看到这种分数,我既没有开心,也没有难过。我只想问,接下来该怎么办?越花时间准备,听力考的分数越低。亏我还是写过托福听力攻略的人。反正花时间在准备考试上也没有提升,不如多玩一玩,那所以说我的懈怠也是合理的。托福考试嘛,其实就是看运气。包括两年前的那次在内,5次考试,我听力只有两次上了25分。我的听力水平其实很差。碰到运气好了,刚好听清了,分数就很高。多数(>50%)情况下,分数是很低的,能反映我真实水平的。写作也是,运气好,考官心情好,给了个26分。其实我水平没有那么高。我还能做什么呢?只能多考几次,撞撞运气了。

越想我就越不服气。为什么我总是习惯于责怪自己呢?就不能把失败归咎于外界吗?我不是曾经说过,托福听力有问题,不能反映考生的真实听力水平吗?今天的身体状态难道没有影响我考试吗?我没有问题,就是客观情况有问题。

想着想着,第一种声音又冒出来了:那为什么在半数以上的考试中,我的听力成绩那么差?这不就是菜吗?考试成绩就是一切。考得差了就是自己的问题。

自我斗争了半天,我决定用一种公平的方式来审判:在下次考试中,我将尽可能排除一切客观上的不利,用最佳状态来考试。如果还是考不好,那就是我英语水平有问题;如果考得好,过去的事情就一笔勾销,我可以光明正大地把考试失利甩锅给考试状态。这时,我才发现我之前有多么愚蠢。考试准备得用不用功另说,为什么我不把应试状态调整到最好呢?睡眠不好调整,但是考试时间是可以调整的啊。我可以早上晚一小时再考,然后喝一瓶红牛拉满状态。提高应试状态比努力准备考试要简单得多,却又有效得多啊!

我再次分析起了现在的学习情况。这两周我都没练过阅读,全是在考场上边考边练。在真枪实弹的练习中,我的阅读技术已经在不经意间练得炉火纯青。这次考试我在状态极差的情况下都考了28分就是证明。而下次考试我会状态拉满,岂不是随随便便就能考个满分?这样一看,阅读就不用练了。写作练了这么久,综合写作也没有提升空间了,独立写作练得也没效果了,维持在22分以上应该没问题。剩下要练的只有听力和口语。听力我曾经拿过27分,我只要练回当时的状态就行了。至于口语,从第三次考试开始,我考试时的心态就出现了问题。所以,这次口语准备的重点是做到不要紧张。只要口语考出正常水平,不比22分低,总分也够100了。一周的准备时间是足够的。我毅然订下了下周二早上10:50的托福家考。

这一周的头几天,我把所有准备时间都放在了听力上。和以前的练习方法略有不同,现在,我只在每天最清醒的时候,用最集中的精神去做题。一旦觉得困了,注意力不集中了,我就去躺一下,绝不让状态差成为借口,也免得错太多题影响心态。调整了状态后,我的练习正确率果然高了不少。

周五,听力练得差不多后,我开始练口语。正好,第三次考试的成绩也出了。由于元旦放假,成绩出得晚了几天。在我状态最差的这次考试中,我的阅读/听力/口语/写作成绩分别是25/22/17/22,总分86,创下了最低分的纪录。这只有17分的口语分数深深扎在我的心里,导致我在练口语时,越说越紧张,越练越不利索。我只能勉强安慰自己,哪怕状态再差,写作都能考22分。如果阅读和听力能接近满分,口语22分左右就可以了。现在只要把口语练到以前的水平就行,不要有太大压力,不用练得太好。就这样,最后三天我一直在练口语。

1月10日,周二,早上10点,我懒洋洋地从床上爬起来。吃完早饭,灌下大半瓶红牛,我精神饱满地坐到了电脑前。这是第五次考试了,是拼上个人名誉的一战,绝对不容有失。熟练地通过安检后,我又一次进入了阅读题的测试。一看到满屏的文字,我下意识地打了个哈欠。我立刻为这个哈欠担心了起来:「怎么回事?我还是没睡醒吗?红牛没有用吗?」不行,我不能在这里停下!我眼睛一瞪,聚精会神地读起文章来。还好,能量饮料是有用的。自此之后,我就再也没打过哈欠,状态神勇地横扫了前两篇阅读。做到第三篇阅读时,我再次被一道题卡住了。好在这次留的时间非常充足,哪怕是在第三篇文章浪费了点时间,我还是给第四篇文章留了完整的18分钟。最后,我完美利用时间,十分确定地答完了每一道题。

之后是听力。我主观感觉自己的发挥和之前几次都一样,听力材料听得很清楚,完全看题目里的信息我是不是恰好记住了。不过,客观上来看,我的发挥确实好了不少。有一道很恶心的题是问「下面这些行为是左脑还是右脑负责的」,等于一道题问了好几个小问题。由于我的笔记非常全面,轻松地把这题回答出来了。

中途休息后,继续进行口语考试。口语是我最担心的一部分。考试还没开始,我的心脏就砰砰跳个不停。第一道独立口语题一出,我就傻眼了:「有些人喜欢早上学习,晚上工作,有些人则相反。你更倾向于哪一种呢?」哪有这种题啊?我爱早上工作早上工作,爱晚上学习晚上学习,哪有什么理由啊?15秒的准备时间里,我愣是一个字的回答也没想出来。开始录音,我只好想到什么说什么,连珠炮一般地把想到的内容全说出来。说完一看,还有一半的时间,内容太少,太空洞了。我只好勉强补了一两句。这道题做得极其糟糕,我狼狈地进入了下一题。很巧,第二道题目一念,我惊讶地发现这道题是第一次还是第二次考试的原题。这整套口语题我都做过!我又是惊喜又是担忧。惊喜的是后面的材料我都记得,怎么回答我都有数了;担忧的是我的状态十分糟糕,之前答起来毫无困难的题,这次就答得这么费劲。但不管怎么样,作为一个讲武德的人,我还是当成第一次做,好好地按流程记笔记,按现场记下的笔记作答,没有在念题目的时候就开始凭记忆把回答准备好。后面这几道口语题答得都还正常。

又到了最后的写作部分。不像之前几部分的考试,写作的时间非常充裕。不管之前再怎么紧张,在写作时多数人都能冷静下来答题。我这次就很不巧,综合写作刚开始没多久,考试程序崩溃了。电脑跳转到桌面上,我突然发现我的工作应用忘了关,右下角有个图标在一闪一闪。我连忙点开应用,回了领导一句话。正当我准备关掉应用的时候,鼠标的控制权突然被考官抢了过去,重新点到了考试程序上。我瞬间惊恐起来,担心考官会认为我在作弊。不过考官也没说什么,考试很快又继续了。我就这样战战兢兢地做完了综合写作。独立写作,题目是「有人觉得大学应该收费,有些人觉得大学的学费应该由政府承担。你支持哪种观点?」这别说拿英文了,拿中文要我半小时内写一篇看上去合理的文章都很难。看到这个题目,第一想法是如果学费太高,穷人就上不起学了。可是,这只能支撑一个观点,字数一定写不够。我只好选择支持大学收费,让大学收费和高质量的教育关联起来,再分别谈高质量的教育对个人、学校、政府的好处,勉强凑够了分论点。

考试结束,第五次客观题开奖了。我感觉这次阅读做得很好,但听力还是有几道不确定的题。失败这么多次了,也习惯了,考得差就差了吧。于是,我不抱希望地点进了查分页面。接下来,我看到了两个惊人的分数:阅读30,听力28。喜悦感瞬间传遍了我的全身。要是几周之前,我肯定就立刻开心地跳起来了。可是,有了上次半场开香槟的经历,伴随着喜悦感的,是一种更沉重的恐惧感。无数的负面想法扑面而来:我的口语可是考过17分的,要是这次再考个17分怎么办?考官要是把我中途点开工作应用的行为当成作弊,取消考试成绩怎么办?就这样,看着刚考出来的高分,我压根不敢庆祝,只是呆呆地坐着。十分钟后,我突然感到全身乏力。我这才想起,现在已经是下午3点了。我已经非常饿,非常疲惫了,完全是靠能量饮料才撑到了现在。

接下来的几天,我没有再准备考试了。做其他事也提不起劲,于是我一有空就会来算分。第四次考试的分数是在周三,也就是考试后第八天出的。阅读/听力/口语/写作的分数分别是28/21/20/23,总分92。如果口语和写作的分数还是和上周一样,那总分就有101分,够了。可是,上周我口语还只是考了20分。这说明我的口语还是没找回状态。万一口语再考一次17分,分数不就不够了吗?就是这样,我用着各种方式去估计第五次考试的分数。从理性上思考,我上100分是很稳的。但是,有了第二次99分的经历,我再也不敢有乐观的想法了。

一边算着分,我还一边估计着下次出分的时间。第一次出分用了九天,第二次和第四次用了八天,第三次因为假期的原因用了十天。可见,八天或九天出分是最可能的。另外,每次通知出分的邮件都是8:05左右发的,意味着早上8点大概就可以看到分数。根据这些分析,我准备从下周三早上开始,每天早起看看有没有出分。

在周三之前,我的日常生活仿佛冻结了起来。我就像沙漏里的沙子,我的生活意义就只是单纯地见证时间的流逝。周三清晨,我一直睡得不踏实。到了早上7点,我在没有闹钟的情况下突然清醒了过来,死盯着邮箱的界面,等着8点到来。7点1分,……,8点1分,8点2分,……。等到了9点,分数还是没有出。我知道今天是不会出分了,如断了弦一般躺下并睡了过去。

又在紧张中度过了一整天后,周四清晨,我以同样的方式在7点的时候醒了过来。如我所料,8点,出分了。这次,我没有看错,精准地找到了本场考试的总分——103分。阅读/听力/口语/写作分别是30/28/22/23。如果阅读和听力的发挥稍差,和第二次考试一样,总分也有100分;如果口语和上次一样只有20,总分也有101分。可以说,我的托福是实打实地有100分的水平。哪怕有几门发挥较差,也不影响总分超过100分。反复确认了考试分数后,我总算是松了一口气。在我的「个人名誉审判」上,我总算证明了自己的实力,总算有资格把以前的失败归咎于状态。考完了,胜利了,说什么都是对的。

周六是1月21日,除夕。我有幸能快快乐乐地过一个春节。1月31日之前,我考了五次托福,总算考出了100分以上的成绩。整个考试准备过程可以说是非常狼狈。如果身边有别人说他考托福考了5次才考到100,我口头上或许会加油打气,但内心里肯定会把他嘲笑一番。但我还是想把这段不是那么光彩的经历分享出来。我认为这段经历可以帮助到很多人。

经验

虽然我在高考之后只经历过托福这一种大考,但我现在也很能理解参加其他考试(比如考研)的考生。没有老师天天监督指导,完全凭自己去找学习方向,这确实不是一件简单的事。很多情况下,有效的学习时间不多,调整学习状态(娱乐)的时间反而会更多。这些情况都是正常的,人人都是这样过来的。有害的不是不学习本身,而是自我指责的态度。负罪感会带来压力,不当的压力才会给正常学习带来阻碍。因此,准备考试时要克服的不是想要休息的心态,而是自己给自己过度施加的压力。不管怎么样,最后考完了,考好了,没人会管你过去做了什么。我复习考试的状态也非常糟糕,经常拖拖拉拉。最后考五次也勉强熬过来了。我觉得很多人的准备情况是比我好的,调整完了心态,肯定能够比我更顺利地考完考试。

自己准备考试时,最重要的是要能够自测,随时得知自己当前的水平。只有这样,你才能知道自己的复习有没有用,成绩有没有提升。另外,最好是能够把要学习的内容用进度表示,比如看多少门的内容,背多少单词。进度的完成能够鼓舞自己。最后,如果有条件,可以和准备同一门考试的同学一起学习。大家不要攀比学习进度,而只是一起分享学习的过程,聊聊天,分担一下压力。如果心态正常,能够随时检测能力是否提升,自学考试不是什么难事。

当然,托福考试有一点特殊。托福本身的学习内容不多,不像其他考试要花很多时间去学你以前从没有学过的知识。从这点来看,托福似乎可以快速地准备好。但是,托福的水平难以测试。托福有一半的内容是主观题,作为一个考生,你是不知道这些题目是怎么批改的。因此,你无法知道自己当前的水平,也难以知道自己的复习是否能提升成绩。没有可以用进度表示的内容,没有自测方式,很多时候你根本不知道怎么复习是有效的。准备托福就像做优化函数求不了导数的优化问题,正常算法根本行不通。

对于自学托福的考生,我还有更多的经验想要分享。不谈准备考试时的认真程度,如果我提前知道了这些经验,我的准备肯定会更加顺利。我非常想把这些经验分享出来。

自学托福的注意事项

  1. 不要在第一次考试前估计自己的托福水平。在我的备考经历中,我曾经用两年前的托福成绩以及这两年英语水平的提升来预测我现在的托福水平。事实证明,我高估了自己的提升,低估了考试的要求。如果没有天天讲英语的环境,口语和听力是不会大幅提升的。而如果又没有经常写英文论文的话,写作水平也不会有太大提升。因此,对于国内大多数理工科学生,只有阅读水平会在大学期间得到提升。另外,哪怕你英语各方面都学得很好,你也要花一些时间去学习应试技巧,把英语水平转换成托福成绩。总之,在实际考出一次成绩之前,你是难以估计自己的托福水平的。最好多留一点时间,在准备得差不多的时候就可以不带压力地先考一次试,看看自己的水平。
  2. 自学口语和写作是很难的。阅读和听力都是客观题,你可以靠对答案来不断反思提升。但是,如果完全不借助外界的帮助,你是难以评估自己的口语和写作水平的。无法评估自己的水平,自然也就难以找到提升的方向。因此,如果想完全自学上100分,一定要把主要时间花在阅读和听力上,这两门要尽可能做到满分。还有时间可以去练写作。至于口语,我看了很多托福考试经验,多数自学托福的人都说口语不好提升。我也建议口语练到22分左右就够了。大多数学校不会要求更高的口语分数。相对地,如果非得要报班学习,最好是请别人帮你提升口语水平,再是帮你批改作文。阅读和听力则完全没有报班学习的必要,全部可以自学。
  3. 注意考试状态。托福考试不仅是在考英语,还在考短期记忆力。在做阅读时,你要大致记住每段的主要内容,这样才能快速地答完最后一道六选三;做听力时记忆就更重要了,答题时八成靠记忆,两成考笔记。口语和写作倒还好,只要笔记记得好,不需要靠脑子来记忆。因此,考试时,一定要保持清醒,保持注意力集中,这样才能记得住东西。托福和大学里的考试不太一样,不是你会就是会,不会就是不会,你还得在有限的时间里把题目答完(我觉得托福甚至比GRE还吃状态)。这一点和高考很像。考前一定要好好调整作息,拿备战高考的状态来备战托福。

总结一下自学托福的整体策略。不管你的英语水平有多高,最好是至少留三个月时间来从容地考完托福。第一个月好好准备,主要是准备能够自测的阅读和听力。写作和口语熟悉题型,保证能回答出来即可。由于不能自测,提升写作和口语的效率是极低的。哪怕不花太多时间准备,也没有关系。准备得差不多了,就可以先考一次试,测试一下水平,熟悉一下考试流程。考完了,如果成绩还差一点,第二个月再开始根据考试成绩做进一步的提升。要考100分以上,最好是阅读听力接近满分,口语22左右,写作能考多高考多高。根据考试结果,哪一部分离目标远就去着重提升哪一部分。至少预留一个月来进行考试,不要让不充足的时间给你带来压力。如果准备时间不够,想要报班,那就只报班提升口语和写作。

接下来,我再详细谈一谈托福的四个部分分别该怎么准备。

托福听说读写的提升方法

基础

网上有很多人会讲自己的备考经验。但是,如果英语基础不同,准备的策略也不同。因此,我想定义一个开始准备托福的最低英语水平要求。这个水平是大多数人开始准备托福的水平,适用性较广。如果你还没有达到这个水平,可以先打基础,再开始针对托福进行准备。

要学习托福,听、读、写能力要达到高考接近满分的水平。此外,要记住大部分四六级单词。对于口语,要能够在提前准备的情况下对着PPT做英语课堂展示。

听力和写作的要求应该没什么争议。毕竟大多数大学生的听力和写作都是高考水平,再之前也记不起中学老师是怎么教听力和写作的了。

对于阅读水平,我认为除了有高考水平外,还需要多记一些单词。不必去背托福单词,背完大部分四六级单词就行了。四六级单词其实是英语里的常用词。如果你熟悉了四六级单词,那么你阅读日常的英语文章是不会有单词上的问题的。剩下的一两个不会的单词,你查一查,也就记住了。我不建议去死记硬背单词,尽可能通过阅读的方式自然记忆单词。

由于各地教育水平的不同,口语很难找到一个统一的基础。在我看来,开始练托福口语之前,只要会用英语表达意思就够了。哪怕用的单词很简单,或者某些单词不会,都没关系。这种口语的基础可以通过英语演讲水平反映出来。大学里的英语课是会要求做简单的演讲或对话的,如果你能在认真准备后完成课堂展示(不必是即兴的),英语的口语基础应该就没有问题。

阅读

要做好托福阅读,要从语法、单词、阅读方法、应试技巧这几方面学习。

英语的语法不难,高中学的语法够用了。哪怕什么定语从句、宾语从句的具体规则都不记得了,也没关系,看几篇文章就回忆起来了。问题是,托福的阅读文章中有不少长句。常常有句子刚说到一半,插入一个逗号,接个从句,又接回之前那句话。这导致我们读着读着就忘掉句子前半部分在讲什么了。刚接触托福阅读时,是会碰到这种句子语法结构分析不清楚的问题。这时,可以花点时间去分析一下这些长句。只要弄懂句子主干是什么、哪个从句是修饰哪个词即可,不用过分深究语法细节。多读几个这种句子后,语法上就不会有问题了。

如前文所述,开始做托福阅读之前,有四六级的单词基础即可。剩下的单词可以通过阅读自然地学习。除去四六级的常用词外,托福的阅读材料常常会包含一些专有名词、历史名词。碰到这种看不懂的名词是非常正常的一件事,千万不要慌张,知道它们是指代某个物体即可,不懂这些单词不影响上下文的理解。如果碰到了没背过的形容词,就去查一查,稍微记一下。另外,我发现考试里的阅读单词比TPO的更简单一点,基本上都是英语里的常用词。如果做TPO碰到了较多的生词,也不必太担心。

打好了语法和单词的基础,接下来最重要的就是阅读文字的方法了。如果不限时间的话,我们当然可以一个单词一个单词去翻译,一句话一句话去理解。可是,在考试中,时间是有限的,我们必须要学会如何快速阅读英语文章。我个人认为,想读好英语文章,要能够带着自信去读,就像读中文一样。看网上的中文资讯的时候,我们肯定不会逐字逐句去读,而是很快地扫两眼,哪怕内容没看全,也大概知道文章在讲什么。读英语文章也是一样。千万不能去先把单词翻译成中文再理解,或者一边念出来一边理解。要敢于看到几个单词,就去脑中拼凑这个句子的意思,想着怎样尽快把文章读完。保持这种习惯,我们看英语就能像看中文一样快速。我认为阅读方法是托福阅读中最重要的一环。我的很多阅读方法都是以前看讲GRE长难句的教材学到的。如果准备时间充足的话,可以去看看一些有关GRE阅读的书。学会那些方法后,看托福阅读就是降维打击。另外,如果你一两年后才需要考托福,可以平时就多看看本专业的论文。带着目的去看生活中的英文文章是提升阅读水平的最佳方法。

足够的英语阅读水平,并不能保证你在托福阅读中拿到高分。应试技巧,是把能力转换成分数的关键。托福的阅读题是有一些规律的。比如问「下面哪个句子能够解释文章中的句子」,那些错误的句子要么是漏了信息,要么是逻辑关系搞错了。再比如,六选三中,一些错误的选项会有「一定」、「所有」这种过于武断的描述。这些应试技巧培训班的老师应该会讲。但我觉得,这些技巧最好是自己通过大量的练习领悟,这样就不需要花精力去记忆了。另外,托福阅读题还有一个很重要的技巧:多数题都会问哪个选项描述正确。对于这些题目,你是能够逐个判断出每个选项是正确还是错误的。这样,一道题其实可以从两个角度来做:三个选项的内容文章都没提,所以它们错了;一个选项的内容文章提到了,所以它对了。如果从两个角度你都得到了同一个答案,那这题你肯定做对了。我考试中做阅读题卡住的几次,都是排除了两个选项,觉得一个选项很对,但最后那个选项没找出它错在哪里。最后一次考试出分前我之所以觉得阅读做得很好,也是因为每道题我都确认了两遍。

总结一下,托福阅读只要无脑做TPO即可。在这个过程中,你会自然地学会分析长句,学会如何跳过不会的名词。为了加快阅读速度,你可以有意识地练习一下英语速读。多做一些题目,多找一下题目中的规律,你差不多能够凭借自己学会应试技巧。练习时,别忘了练时间分配。一开始练习时可以先在完全没有时间限制的情况下尽可能提高正确率,之后再在限定的时间内尽可能完成答题。完成所有这些的练习,就足以在托福阅读中拿到高分了。对了,考试前一定要睡醒。六选三其实就是考察你记没记住文章里的内容,要去文章里确认每个描述是否被提及。如果状态不好的话,你可能就记不住文章的内容,做六选三会非常吃力。

最后再分享一下我阅读文章的节奏。托福阅读题是顺着文章内容出的。比如前两题是和第一段相关,之后的两题是和第二段相关,只有最后一道六选三是和全文相关。因此,我阅读文章时,会先阅读一整段,再看这一段的题,题目问到哪我就再把哪看一遍。做完这一段相关的题,再去读下一段。我以前也试过先读全文再去做题,但我发现阅读全文时我对文章的理解很不到位。而一边做题一边读的话,能深刻理解每一段的意思。做完除最后一题外的所有题,也差不多懂了全文的意思。

听力

我之前已经写了一篇托福听力的准备方法了。我觉得这篇文章写得很好,方法非常系统,总结非常到位。可惜,可能文章写得太花里胡哨了,似乎看的人不多。我这里再稍微总结一下那篇文章的意思。

托福听力分三部分去准备:听力能力、记忆能力、理解能力。如果听不懂文章的内容,就去用精听等方法提升翻译英语语音的能力;如果听完了总记不住,就去练习应试技巧,尝试提前预测出题点;明明听懂了题目却做不对的情况较少,如果有,多反思一下做题时的想法,看看是不是做题做得太快了,选项没读懂。练习英语听力时,先想办法把错因归为上述三类中的某一类,再集中练习。

口语

我完全理解不了口语是怎么批改的,也没资格介绍口语经验。非得说我从五次口语成绩中分析出了什么,我只能说,越紧张,口语分越低。我最自信的第一次考试口语的分数反而最高。想口语拿高分,就不要自学,寻找外界的帮助吧。

如果给我充足的时间,我会按这个顺序准备口语:一开始练独立口语,学习一下独立口语怎么想内容(比如一个话题展开两到三个角度,或者观点正着说一遍反着说一遍),保证这题有话可说。之后练后面三道口语题,熟悉题型,知道大概的套路。再之后提升后面三道题的流利度,学习高分回答的答题方式,如一般要说几句话、材料的内容要复述得多准确、如何用连接词过度。最后还有时间,再去练习独立口语,准备一些常用的段子,尽可能减少说不出话的情况。离考试越近,越要多练口语,让自己在考试的时候充满信心。

写作

综合写作比较简单。综合写作会先给一段材料,材料中有一个观点的三个分论点。用三分钟读完材料后,会有一段听力,逐个反驳材料中的三个分论点。听完听力,在可以看到材料的情况下复述听力内容。相比听力测试,这段语音的理解难度很低。演讲人语速很慢,且关键处会反复重复。因此,在做听力笔记时,完全可以把演讲者观点中的关键词全部记下。写作时,只要把关键词串成句子即可。平时练习个一两次,学一学套路,掌握个简单的模板,知道怎么用连接词把句子串起来,考试的时候综合写作做起来就会非常轻松。

多数人讨论托福阅读都是在讨论独立写作。独立写作就完全是根据一个话题自主写作了。独立写作有两大要点:文章内容如何组织、英语用法是否地道。

文章的组织方式可以在短期内学会。准备写作时,尤其是在备考时间不够时,要把重点放在这上面。托福独立写作常常会给一个大家都听过,但不是那么好写的话题。想要漂漂亮亮地在半小时内论证这种话题,哪怕是拿中文,都是不太可能的。不过还好,托福写作考察的重点不是内容是否合理,而是英语表达能力是否过关。因此,考前要多花时间学习展开话题的技巧,知道怎么用恰到好处的废话来完成一篇英语写作。

组织文章内容,又需要考虑三个方面:文章整体结构、每段的分论点、段内句子展开方式。

文章整体结构,指的是你是无条件支持或反对题目的观点,还是一半肯定还是一半否定,还是其他什么形式。有非常多的托福写作教程会讲文章整体结构的分类。我反正只会一种叫做「一边倒」的方法,无条件支持或反对题目的观点,然后每一段都强烈地支持我的观点。这种方式最简单粗暴。其他常用的还有两段肯定,最后一个主体段委婉地让步一下。建议大家选择某一种组织方式,平时练习和考试都只用这种方式。

想每段的分论点,即怎么把话题展开,分成多个方面讨论,让自己有话可说。就比如我正在写的这篇托福经验分享,在介绍托福各个部分的准备方法时,我会讲这个部分要从哪几个方面学习,再详细谈每个方面具体怎么做。问题是,平时写文章,我都是有了要表达的东西,再用清楚的逻辑去写作。而托福写作是逼着你去对着一个话题,在三十分钟内组织一篇空洞无比的文章。我都没有想写的内容,只能生搬硬凑了。所以在我看来,想托福写作的分论点,就是要大开脑洞,能怎么扯怎么扯,只要是和主题相关的分论点即可。各个分论点之间是否有着密切的逻辑关系?没人会在意这个。不同题材,有不同组织分论点的方式。我多数情况是对着题目临场想分论点。我唯一掌握的一种模板,就是分个人、组织(公司或大学)、政府分别讨论。建议平时练习独立写作时,可以多看几个题目,不用写作,就想一想分论点。之后再看看范文的分论点,学一学别人是怎么组织的。

想好了文章结构和分论点,还要知道每一段的内容具体该怎么写。其中,开头结尾的组织方式和主体段的组织方式不同。开头结尾是有套路的,可以提前准备。比如开头先复述一下材料,讲一讲多数人的观点,最后坚决地提出自己的观点;结尾的时候总结一下每个主体段,再重申自己的观点。这两段不用写得很花哨,不是很难。最难的是各个主体段的写法。主体段一般是三个,每段你要用将近100词来详细阐述分论点。没有足够的备考经验的话,很容易碰到主体段没东西可写的情况。我认为,靠自己反复写作是提升不了写主体段的能力的。最好多参考别人的范文和一些教辅书,学习一下段内的叙述方式。一般来说,一个主体段,可以先提出观点,再用一两句话去详细解释,最后用示例丰富观点。句子之间多用连接词,多构建因果、递进、比较等关系。示例不用像高考语文一样非得用名人示例,也可以用生活中的普遍情况或者自己的例子。介绍这种写作技巧的书和资料非常多,建议大家去找一下,一定要学习别人的经验,不要闷头反复写文章。

组织段内内容时,有一些赖皮的方法。比如,可以一句话,同样的意思,颠来倒去地说。再比如,可以准备一些万能的句子,放到文章的开头结尾,或者某一主体段中。甚至文章话题不难的话,你可以把提前背好的一整段都搬过来。

举个例子。 “xxx plays a crucial role in our daily life. That is to say, xxx is important to us”。我见过诸如此类看上去华丽,但全是废话的句子。没办法,这种优美的句子确实好使,只要你不是全文都用这种废话就没事。

我对这些方法不做评价。反正托福是一个功利性很强的考试,能考高分怎么做都是对的。如果你时间有多,可以去背一背做一点准备。

除了文章的内容,剩下要考虑的就是如何让表达更加地道。比如,“孩子是祖国的花朵”显然就不是英语里会出现的说法。妥当的语言表达是一件很难在短期内学会的事。哪怕是中文,由于互联网流行词几个月一换,几年前的文章现在看来也不是那么时尚。没有语言环境是难以真正地提高语言表达的。因此,我推荐自学的考生完全放弃提升语言表达。

当然,我最近才发现一种效率较高的自学语言表达的方式:利用ChatGPT。你把自己写的文章输入进ChatGPT,让它帮你润色,它能够在完全不改原意的情况下让文字观感大幅提升。这等于是有一个教师手把手带着你练写作,告诉你哪里怎么改,怎么提升。ChatGPT的出现完全解决了英语写作无法自测的问题。我认为,如果使用得当的话,ChatGPT完全可以代替写作老师,让学生自己就找到提升写作的方向。很可惜,ChatGPT出现时我还在闷头准备托福,没来得及深入探究ChatGPT的用法,也没有使用ChatGPT进行写作练习的测试结果。我大胆预测,多数人用ChatGPT提升自己的英语表达后,托福写作至少能高2分。

以上内容主要是我从别处学到的托福写作准备方法,可以保证这些准备的方向是有效的。接下来我再稍微谈一下我自己的托福写作考试经验,并对托福写作的准备方法做一个总结。我也很纳闷,不知道为什么第一次考试写作拿了26分,后面就再也拿不到了。现在想来,估计是因为那次的题目比较简单,我的段内表达都比较流畅。后面每次独立写作时我都感觉很不顺利。在这几次考试中,我都举了和自己相关的例子。这些例子太假了,完全没有说服力。因此,我建议按以下顺序准备托福写作:尽快选择一种文章组织方式,比如最简单的「一边倒」式,并稍微学一下开头结尾的套路。之后花主要时间学习如何想分论点和组织段内句子,要做到有话可说,能够流畅自然地写完三个主体段。最后时间有多的话,可以借助ChatGPT提高语言表达能力。顺带一提,我每次独立写作就是300多字。只要够300字,字数多少并不影响成绩。当然,你有话可说,可以写出很多内容,那再好不过了。

总结

想自学托福上100分,建议写作和听力拉满,尽力学写作,口语差不多即可。

写作对着TPO无脑练就行。除了对答案提升解题能力外,着重提升快速阅读英语文本的能力。

听力也是对着TPO练。不过,要先分析自己的弱项,不要上来就去精听。在反复的自测中,逐个提升听懂、记住、写对的能力。

综合写作练一两次熟悉题目即可,稍微准备一个简单的全文结构模板。独立写作先学文章整体结构和开头结尾的技巧,再着重学习提论点和填充段内内容的方法。可以借助ChatGPT批改作文。

听力写把独立口语练到能说的程度,再熟悉三道综合口语的题型。之后主要提升综合口语,有时间多再认真准备独立口语。说口语时一定不能紧张。

搜索引擎根本搜不出有用的托福攻略。建议上留学论坛找前人的经验分享,这些帖子里经常能够找到许多学习资源(教材、作文范文)。四个部分中,写作的技巧是最需要向别人学习的。

在这篇文章中,我动之以情,晓之以理,把我在五次考托福中学到的东西全讲出来了。从今天起,我的脑中再也不想出现「托福」这两个字了。希望大家阅读后有所收获,早日战胜托福考试。

最近,我在考托福。第一次考完,我惊讶地发现我的听力只有19分(满分30)。我两年前考的成绩都不止这么一点。这两年我也一直在接触英语,英语水平不可能退步。如果托福真的是一个合理的,能够反映考生真实水平的考试,那我不可能考出差别这么大的分数。因此,我认为,考出这么差的分数,不是我的问题,是托福考试的问题。托福考试过分强调应试技巧,而无法稳定地反映我的水平。为了证明我的观点,我不服气地宣誓道:「我要用一周时间,学习听力应试技巧,考出一个较高的听力分数。」一周后,我又考了一次托福。结果很喜人,我的听力考了27分。

在这几天里,我总结了网上的托福听力准备方法,用一套规范的算法流程把方法表达了出来。我将分享一下这一套和深度学习算法形式相似的托福听力准备方法。我会从头把方法的背景、解决方法讲清楚。哪怕你不需要准备托福考试,或者说对深度学习没那么了解,也可以把这篇文章当故事读一遍。

托福听力规则

托福听力主要涉及两类材料:对话与讲座。对话通常发生在学生与教授或学校工作人员之间,描述了校园中常见的一些讨论、询问。讲座则模拟了真实的课堂教学,讲师会对某一专业话题做简要的描述,偶尔会穿插几句学生的提问。讲座涉及的知识面很广,常常会谈及艺术、历史、生物学、地理学、心理学等领域。不过,这些讲座不会讲特别深入的内容,也不会讲过于偏离生活的概念,保证多数人都能听懂讲座的内容。

一场无加试的托福听力由两轮组成。每轮会听1段对话和1~2段讲座。对话有5题,讲座有6题。

托福听力的答题形式和国内多数英语考试不同。每段听力中,考生只有听完了听力材料后,才能看到题目。并且,只有确认提交了前一题才能答下一题。当然,可以在听的同时记笔记。

题目全是选择题。每段听力材料给3~4分钟,基本不会有时间不够的情况出现。

托福这种不允许提前看题的考试模式把考生的记忆力也变成了考察目标之一,为考试增添了不合理的难度。后续算法的诸多改进都是为了解决「记不住」这一问题。

托福应试流程

大多数人在初次接触托福听力时都会采用这一套非常直观的算法:

但是,这套算法有一个问题:无论我们的听力水平多么优秀,都不可能把材料原原本本地记忆下来。一旦有题目考察了一个我们没记住的地方,这道题就答不上来了。

因此,多数托福教程会给出一套改进的算法。这套算法把「听材料」细分成了两部分。第一部分是「语音转文字」,这是我们大脑自己训练出来的功能。理解了听力材料的内容后,我们根据一些先验知识(知道听力材料的哪些部分容易出考题),对部分内容做重点记忆。重点记忆的方法可以是竖起耳朵集中注意力,也可以是用笔记记录关键词。最后,根据重点记忆的内容和大脑中残留的其他记忆,回答问题。

这套算法出色地把托福听力分成了三个独立的子任务:语音识别、记忆、阅读理解。这三个子任务恰好对应了三种要考察的能力:听力能力、记忆能力、理解能力。把这三种能力拆开来讨论是很有必要的。如果你没有意识到自己哪种能力相对相差,盲目地做题,尝试同时提升这三项能力,那你的学习是十分低效的。后文我们将基于这一套算法讨论如何分别提升这三种能力。

错因分析

在正式开始学习之前,我们一定要诊断出自己哪方面的能力有所缺失,进而对症下药,从最差的一项开始练习。为了找出做听力的问题,我们要进行错因分析。

为了考察听力、记忆、理解这三种能力,我们固然可以用做托福听力题以外的方式去分别测试这三种能力。但是,使用其他测试方式的话,我们不能保证我们测试出来的能力恰好是托福考试要求的能力。比如,你可以拿托福阅读来测试自己的理解能力。但是,由于托福阅读的理解难度比听力的理解难度要大,哪怕你阅读做得不好,也不能说明你听力就理解不好。因此,做托福听力题这件事本身就是最好的测试方式。

可是,正如前文所述,做托福听力会同时考察三种能力。该怎么分别考察这三种能力呢?其实,使用一些巧妙的控制变量法,就可以把这些能力区分出来了。我把网上提出的各种错因分析方法加以总结融合,提出了一种「反掩码错因分析」法。

什么是「反掩码」呢?众所周知,掩码 (mask),指的是计算机中用于屏蔽其他数据的一种数据,有时也泛指屏蔽数据这一过程。那么,我提出的「反掩码」,指的就是把原本不透明的数据变得透明。

在托福听力中,听力材料是不透明的。你需要通过自己的听力能力把听力材料变成可理解的文字。如果直接把听力材料变成了阅读材料,你就可以直接根据听力原文答题。这样,做题考察的就只有理解能力,而不再考察听力和记忆能力了。

因此,为了考察自己的理解能力。可以先听一遍材料,做题,不看答案,读一遍原文,再做题。第一遍做题是正常的练习,第二次做题是控制变量。如果第二次做题还是错了很多,就说明理解能力不行;如果第一遍做题相较第二遍错了很多,就说明听力和记忆存在问题。

同理,为了进一步区分听力和记忆的问题,我们可以调整反掩码,使用更精妙的控制变量方式:先听一遍材料,做题,不看答案,再听一遍材料,再做题。在第二次听力的时候,你已经知道题目了,不存在记不住的问题。如果第二遍答题还是错了很多,就说明听力和理解有问题;如果第一遍相较第二遍错了很多,就说明记忆有问题。当然,如果你的理解已经基本没问题了,在这一步就可以排除掉理解能力的影响,直接区分听力问题和记忆问题。

综上,「反掩码错因分析」的流程如下:

  1. 找出几套听力题。先听一遍材料,做题,不看答案,读一遍原文,再做题。主要分析自己的理解是否存在问题。
  2. 理解能力最容易提升。先想办法提升理解能力。
  3. 再找出几套听力题。先听一遍材料,做题,不看答案,再听一遍材料,再做题。区分听力问题和记忆问题。
  4. 根据诊断结果,先后提升听力问题和记忆问题。

能力提升

诊断出了问题后。就应该考虑如何设计子任务分别训练这三种能力了。

听力能力

使用听力能力时,输入是英语音频,输出是大脑中可理解的英文文字。这一过程完全由大脑的本能决定,几乎不需要主观思考。因此,我们可以设计一个非常直接的子任务:使用任意一种英语音频,一边听,一边「输出」自己的听到的文字。听完音频后,比较自己的输出和原文,看看自己哪些地方没听懂。不断反思,让大脑自己提升。

网上的很多托福听力攻略会叫你去「精听」,「听写」,或者使用什么「影子跟读法」。其实,所有这些方法都只是为了提升听力水平。他们的目的都是构造一个输入音频输出文字的训练任务。只是「输出」的方式不同而已。在我看来,输出文字的方式,可以是复述,可以是听写,甚至不用讲出来,在大脑里有个印象就行。所有这些具体的方法里,我不推荐听写法,因为你大量的学习时间都会浪费在写单词上。听完一句话后,复述这句话即可。至于是一句话反复听,还是听完一整段材料,这些形式都不重要。保证你在强迫自己不断输出听到的内容即可。

另外,与其纠结听力练习的具体方法,不如去花一点时间准备恰当的听力材料。对于有高考英语听力水平的人,直接拿托福官方题目(TPO)的听力材料练就可以,类似于TED的知识分享、新闻播报也可以。看电视剧的提升可能没那么快,因为电视剧的语速较快,且通常只有日常用语,与托福听力材料的内容不符。我个人推荐去上自己专业的英文公开课,比如大名鼎鼎的「MIT线性代数」。这些公开课语速适中,用语朴素,比较容易听懂,形式与托福听力类似,还可以顺便学一下专业知识。

记忆能力

托福听力考试的记忆分两类:第一类是被动记忆,也就是你听完整段材料后自然残留在大脑里的记忆;另一类是主动记忆,是你根据以往的答题经验,对材料中你认为的重点段落的记忆(或者笔记)。被动记忆我们没法操控,只能祈求考试当天头脑清醒一点。因此,练习记忆时,主要是练习托福听力应试技巧,提高对出题点的灵敏度,并且做到在不影响听力的情况下记下笔记。

在培训班或者网上,都能找到大量的托福听力技巧。这些技巧告诉你什么地方容易出题,听到哪些词的时候做笔记之类的。但是,我觉得背技巧的效率是很低的。最好的方式是,自己去尝试发现技巧,有了一定的经验后再去和别人的技巧对比。

那么,怎么去学会洞察听力材料的出题点呢?有两个方面的事要做。一方面,做完了题目后,总结归纳常见题型,并且找题目对应的原文,总结规律;另一方面,在听材料时,多做一点笔记,做题时看看哪些笔记用到了,哪些没有。甚至,对于以前练过的材料,可以直接跳过听力,读原文,猜出题点。通过正向和反向的练习,很快就能掌握技巧。

作为示例,我来分享几个我发现的技巧。

  • 对话材料90%会问对话发起的原因是什么。注意,这道题不是问整段对话的主题是什么,而是问学生为什么找老师或老师为什么找学生。很可能两个人寒暄了老半天,学生突然支支吾吾地说出自己过来的原因,然后话题一下就飞走了。如果你没有预判出题点,很可能这一句话就被你忽略了。
  • 讲座有时会先讲理论,再讲示例。题目会问这个示例是揭示哪个理论。如果示例里恰好冒出两个专有名词,你又不知道这里会出题,这个例子就会被你忘掉了。因此,听到示例,赶快把示例里涉及的几个名词记下来。
  • 讲座中,老师和学生都可能会表达对于某一理论的态度。尤其是结尾,老师很可能冷不丁地说「虽然这个理论很有名,但我并不是很认可」。题目很喜欢考这些态度。你可能辛辛苦苦听了4分钟的材料,想着听力要结束了,可以放松一下了。结果最后这两句很重要的态度就被你忽略了。

找到材料中的出题点,其实只是比较初级的技巧。如果你想成为高手,还有一种高阶的,更一般的方法。我把它称之为「基于注意力的记忆法」。听力材料,甚至是阅读材料,以及我们生活中各种各样的文字讯息,都是有很多废话的。如果你知道哪些话言之无物,你就可以过滤掉这些话,而集中记忆那些有意义的话。更进一步,如果你知道听力中哪些话一定不会出题,那么这些话也可以过滤掉。你可以只把注意力集中在哪些容易出题的有意义的话上。

比如说,老师讲「好久不见」、「我们上节课讲了什么什么,但是这节课……」。这些话都可以直接当成废话忽略掉。讲座时提到某个名人出生于何处,几几年在哪上学,这种过于细节的地方也不可能出题,可以忽略掉。

这种基于注意力的记忆法是很有用的。一来,它可以帮你找到重点和非重点的话,有助于合理安排精力;二来,在知道这句话不重要时,可以放心地去记上一句话的笔记。

这种记忆法不用刻意训练,多听了几段材料后就能自然地知道听力材料中哪些地方是可以忽略的。

理解能力

托福听力中的理解,既包括对材料的理解,也包含对题目和选项的理解。整体来说,托福听力对理解的要求不高。只要清楚地听懂了材料,做题时一般不会因为理解而扣分。

材料理解比较简单。只有介绍一个复杂的理科概念,或者人物表明态度时过于委婉,才可能导致材料理解错误。只要把托福阅读题练好,听力材料不可能有理解问题。

理解题目、选项时则可能会碰到一些问题。比如在主旨题中,每一个选项概括主旨都概括得不是很确切,但是其中三个选项有明显错误,一个选项概括得不全。这个时候,得选择那个概括得不全的选项。为了解决这个问题,只需要多做点题,总结记录选项理解错误导致的错题,很快避开常见的一些坑。另外,听力的时间非常充足,碰到模棱两可的选项时可以多读两遍题,千万不要把题目读错。

综合练习

TPO 30之前的题都比较简单,可以拿这些题目来反复训练各个子能力。预训练好了大脑中的各个子模块后,就可以直接开始做TPO 30之后的题,进一步综合训练所有能力。

综合练习时,应按照正常的应试流程,一边听材料,一边做笔记。听完了材料就做题,对答案。有题目做错了,大致把错因归个类,看看是三步中哪一步出了问题。根据错题的情况做进一步的查缺补漏。

在评估自己的综合练习水平时,除了对答案,还需要反思两件事:材料中的哪几句话没有听清;笔记是否记下了重要信息。

很多时候,你可能听漏了某几句话,但依然把题目都做对了。但这样并不保险,最好是把每一句话都理解透来,保证听力能力没有问题。

哪怕你已经熟悉了材料中的出题点,依然需要练习一下记笔记的方法。首先,你要保证记笔记的时机合理,不能影响听力。其次,你要保证重要的信息没有记漏。在不影响听力的前提下,多记笔记是没有坏处的。因此,反思时,只需要评估哪些题目中考察的信息是漏记的,尤其是哪些导致你做题出错的漏记的信息。

最后,再总结一下各种能力在托福听力中的重要性。理解能力是必须的,也是难度最低的。一定要把理解能力练到满分。听力能力是答题的基础,是托福听力题的主要考察对象。听力能力是最考验基本功,最难在短期提升的,一定要早做准备。记忆能力是把听力能力转换成分数的必备途径,你可能文章听得津津有味,答题时却头脑发昏。记忆能力最好提升,几天内就可以总结套路,掌握记忆关键信息的方法。

方法总结

我们来把这篇文章讲过的托福听力准备方法和应试方法整理一下。

准备考试:

  1. 做几套TPO听力,使用分析错因,找出自己较弱的能力。分析错因时,先听一遍,做题,然后在不看答案的前提下再听一遍或看一遍原文,排除出做题出错的原因。
  2. 从较弱的能力开始,逐个提升提升能力。
  3. 练得差不多了以后,综合练习,查缺补漏,同时练习应试的状态与技巧。

考试时:

  1. 听语音,在脑中把语音变成文字,判断这句话有没有重要信息。
  2. 如果觉得这句话很重要,就着重记在脑子里,或者用笔记记下。对话可以少记或不记笔记。讲座必须记下关键信息,比如举例、分类讨论、态度之类的。
  3. 根据笔记或印象答题。

托福听力题本质上还是在考察听力能力。如果你听力的基础不好,还是要把主要的时间花在打基础上。等听力水平差不多了,可以按照这篇文章介绍的内容,学习一些技巧,把听力能力转换成分数。希望大家能够考出一个不错的托福分数。

附录:托福听力框架图

我在学习今年的一篇和人脸生成相关的论文时,看到了一个约束人脸相似度的 loss。这个 loss 基于经典的 ArcFace 人脸识别网络。ArcFace 是 2019年发表的,这么久了还有人用,说明这是一篇适用性很强的工作。于是,我顺手学了一下 ArcFace 的相关背景。在这篇文章中,我将简要分享 ArcFace 人脸识别网络的发展历程,并介绍如何快速利用它的开源 PyTorch 项目计算任意两幅人脸的相似度。

人脸识别与 ArcFace

人脸识别是在深度学习时代取得较大突破的一项任务:给定一个登记了 N 个人的人脸数据库,再输入一幅人脸,输出这个人是否是 N 个人中的某一个。

多数深度学习算法会用一个 CNN 来提取所有人脸图片的特征,如果输入的图片特征和数据库里的某个特征的向量相似度大于某个阈值,就说明识别成功。也就是说,人脸识别的关键在于如何用 CNN 生成一个「好」的特征。特征的「好」体现在两点上:1) 同一个人的人脸特征要尽可能相似;2) 不同人的人脸之间的特征要尽可能不同。

为了达成这个目的,研究者提出了不同的学习特征的方法。最直观的方式是像学习词嵌入一样,用一个具体的任务来学习特征提取。恰好,人脸识别可以天然地被当成一个多分类任务:对于一个有 N 个人的人脸训练集,人脸识别就是一个 N 分类任务。只要在特征提取后面加一个线性层和一个 softmax 就可以做多分类了。训练好多分类器后,扔掉线性层和 softmax 层,就得到了一个特征提取器。

这种基于 softmax 分类器的学习方法确实能够区分训练集中的人脸,但在辨别开放人脸数据集时表现不佳。这是因为 softmax 的学习目标仅仅是区分不同类别的人脸,而没有要求这种区分有多么分明。后续的多篇工作,包括 ArcFace,都是在改进训练目标 softmax,使得每类对象之间有一个较大的间隔。

为了让不同的类别之间存在间隔,研究者们详细分析了 softmax 函数。假设$x \in \mathbb{R}^d$是人脸图片提取出的维度为$d$的特征向量,它属于$N$类里的第$y$类。softmax 前的线性层的参数是$W\in \mathbb{R}^{d\times N}, b\in \mathbb{R}^N$。则基于 softmax 的多分类误差可以写成:

其中,向量内积$W^T_jx$可以展开为:$W^T_jx=||W^T_j|| ||x|| cos \theta_j$,其中$\theta_j$是两个向量的夹角。如果对向量$W^T_j$和$x$都做归一化的话,再令$b=0$,则新的误差可以写成:

也就是说,对于这种归一化的多分类误差,对误差产生贡献的只有特征向量和$W$的列向量的夹角。那么,我们就可以换一个视角来看待这个误差:$W$其实是$N$个维度为$d$的向量的数组,它们表示了$N$个人脸类别的中心特征向量。误差要求每个特征和它对应的中心向量的夹角更小。

为了让不同的类别之间有更大的间隔,相同类别内部更加聚拢,ArcFace 在误差的$cos$中加了一个常数项。

直观上来看,加这一项就是把角度远离类别中心的惩罚扩大,使得各个类别的数据都更加靠近中心。这一优化是有效的。作者做了一个简单的实验,用8类人脸的训练集训练了一个维度为2的特征。其可视化结果如下(线是各个类别中心的方向向量,点是样本的特征向量):

ArcFace 的核心思想就是其 loss 的设计。ArcFace 的网络架构没有特别的要求,一般使用 ResNet 就行。

使用 ArcFace 开源项目

其实用这个库的时候可以完全不懂 ArcFace 的原理。

可能是由于「ArcFace」这个名字和其他项目撞车了,ArcFace 的官方 GitHub 仓库叫做 insightface。其 PyTorch 实现的网址是 https://github.com/deepinsight/insightface/tree/master/recognition/arcface_torch。

早期该论文没有官方的 PyTorch 实现。有人用 PyTorch 复现了该论文(https://github.com/TreB1eN/InsightFace_Pytorch )。我使用的项目是这个复现版,它更完整一点,包含了人脸预处理代码。

人脸识别任务最简单的例子是输出两幅人脸的相似度。接下来我将介绍如何安装这个项目,并用它来编写一个简单的计算人脸相似度的 demo。

安装这个库很方便,只要一键 git clone 就行。这个项目基本不依赖什么第三方库,环境里有 PyTorch,NumPy 等常见库就行了。

1
git clone git@github.com:TreB1eN/InsightFace_Pytorch.git

获取仓库后,在 README 的链接里下载 IR-SE50 模型 model_ir_se50.pth。随便放在哪个目录里。比如下载 IR-SE50 @ Onedrive

最后,还要准备一下测试图片。我很机智地使用了吴恩达《深度学习专项》里讲人脸识别时用到的图片,分别命名为face1.jpg, face2.jpg, face3.jpg

准备就绪后,就可以编写 demo 了。在做一个人脸相关的项目时,一般会先对人脸做预处理,让所有的人脸图片都有相同的分辨率,且五官的位置对齐。我们的 demo 也是先利用了项目中的人脸预处理库对齐人脸,再调用模型计算相似度。这三张图片的对齐结果如下:

这个demo会输出第一张人脸和第二、第三张人脸之间的相似度。完整的 demo 代码如下(注意修改其中的路径):

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
from model import Backbone
import torch
from PIL import Image
from mtcnn import MTCNN

from torchvision.transforms import Compose, ToTensor, Normalize

mtcnn = MTCNN()


def get_img(img_path, device):
img = Image.open(img_path)
face = mtcnn.align(img)
transfroms = Compose(
[ToTensor(), Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])])
return transfroms(face).to(device).unsqueeze(0)


device = 'cuda'
img1 = get_img('face1.jpg', device)
img2 = get_img('face2.jpg', device)
img3 = get_img('face3.jpg', device)

print(img1.shape)

model = Backbone(num_layers=50, drop_ratio=0.6, mode='ir_se')
model.load_state_dict(torch.load('model_ir_se50.pth'))
model.eval()
model.to(device)

emb1 = model(img1)[0]
emb2 = model(img2)[0]
emb3 = model(img3)[0]
print(emb1.shape)

sim_12 = emb1.dot(emb2).item()
sim_13 = emb1.dot(emb3).item()

print(sim_12)
print(sim_13)

可能是 NumPy 更新的原因,代码在读取.npy时少了allow_pickle=True,会报错。要在mtcnn_pytorch/src/get_nets.py的几个np.load的地方加上allow_pickle=True。不知道在哪加也没关系,报错了再补上即可。

1
2
3
4
5
6
7
8
9
10
# mtcnn_pytorch/src/get_nets.py
# 约55行
weights = np.load('mtcnn_pytorch/src/weights/pnet.npy',
allow_pickle=True)[()]
# 约100行
weights = np.load('mtcnn_pytorch/src/weights/rnet.npy',
allow_pickle=True)[()]
# 约151行
weights = np.load('mtcnn_pytorch/src/weights/rnet.npy',
allow_pickle=True)[()]

修改完毕后,直接运行脚本就行了。其输出大致为:

text
1
2
3
4
torch.Size([1, 3, 112, 112])
torch.Size([512])
0.5305924415588379
0.03854911029338837

第一个输出是模型输入张量的形状。第二个输出是模型输出的特征的性质。了解这两个形状信息有助于我们调用此库。

第三个输出是第一、第二张人脸的cos相似度,第四个输出是第二、第三张人脸的cos相似度。我们已经事先知道了,第一和第二张人脸图片是同一个人,第一和第三张人脸图片是两个人。所以说,这个输出结果非常正确。

在我们自己的人脸项目中,一般都会准备好人脸预处理的代码。因此,在调用这个库时,可以只把 model.Backbone 里的代码复制过去,只使用该项目的模型即可。使用时注意输入输出的形状要求。

参考资料

ArcFace 论文:ArcFace: Additive Angular Margin Loss for Deep Face Recognition

官方仓库:https://github.com/deepinsight/insightface

PyTorch 复现仓库:https://github.com/TreB1eN/InsightFace_Pytorch

《人脸识别的 loss》:https://zhuanlan.zhihu.com/p/34404607 https://zhuanlan.zhihu.com/p/34436551

刚刚学完了PyTorch的并行训练写法,我来分享一份非常简单的PyTorch并行训练代码。希望没有学过的读者能够在接触尽可能少的新知识的前提下学会写并行训练。

完整代码 main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import os
import torch
import torch.distributed as dist
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torch.utils.data.distributed import DistributedSampler
from torch.nn.parallel import DistributedDataParallel

def setup():
dist.init_process_group('nccl')


def cleanup():
dist.destroy_process_group()


class ToyModel(nn.Module):

def __init__(self) -> None:
super().__init__()
self.layer = nn.Linear(1, 1)

def forward(self, x):
return self.layer(x)


class MyDataset(Dataset):

def __init__(self):
super().__init__()
self.data = torch.tensor([1, 2, 3, 4], dtype=torch.float32)

def __len__(self):
return len(self.data)

def __getitem__(self, index):
return self.data[index:index + 1]


ckpt_path = 'tmp.pth'


def main():
setup()
rank = dist.get_rank()
pid = os.getpid()
print(f'current pid: {pid}')
print(f'Current rank {rank}')
device_id = rank % torch.cuda.device_count()

dataset = MyDataset()
sampler = DistributedSampler(dataset)
dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)

model = ToyModel().to(device_id)
ddp_model = DistributedDataParallel(model, device_ids=[device_id])
loss_fn = nn.MSELoss()
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)

if rank == 0:
torch.save(ddp_model.state_dict(), ckpt_path)

dist.barrier()

map_location = {'cuda:0': f'cuda:{device_id}'}
state_dict = torch.load(ckpt_path, map_location=map_location)
print(f'rank {rank}: {state_dict}')
ddp_model.load_state_dict(state_dict)

for epoch in range(2):
sampler.set_epoch(epoch)
for x in dataloader:
print(f'epoch {epoch}, rank {rank} data: {x}')
x = x.to(device_id)
y = ddp_model(x)
optimizer.zero_grad()
loss = loss_fn(x, y)
loss.backward()
optimizer.step()

cleanup()


if __name__ == '__main__':
main()

假设有4张卡,使用第三和第四张卡的并行运行命令(torch v1.10 以上):

1
2
export CUDA_VISIBLE_VERSION=2,3
torchrun --nproc_per_node=2 dldemos/PyTorchDistributed/main.py

较老版本的PyTorch应使用下面这条命令(这种方法在新版本中也能用,但是会报Warning):

1
2
export CUDA_VISIBLE_VERSION=2,3
python -m torch.distributed.launch --nproc_per_node=2 dldemos/PyTorchDistributed/main.py

程序输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
current pid: 3592707
Current rank 1
current pid: 3592706
Current rank 0
rank 0: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:0')), ('module.layer.bias', tensor([0.6403], device='cuda:0'))])
rank 1: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:1')), ('module.layer.bias', tensor([0.6403], device='cuda:1'))])
epoch 0, rank 0 data: tensor([[1.],
[4.]])
epoch 0, rank 1 data: tensor([[2.],
[3.]])
epoch 1, rank 0 data: tensor([[2.],
[3.]])
epoch 1, rank 1 data: tensor([[4.],
[1.]])

下面来稍微讲解一下代码。这份代码演示了一种较为常见的PyTorch并行训练方式:一台机器,多GPU。一个进程管理一个GPU。每个进程共享模型参数,但是使用不同的数据,即batch size扩大了GPU个数倍。

为了实现这种并行训练:需要解决以下几个问题:

  • 怎么开启多进程?
  • 模型怎么同步参数与梯度?
  • 数据怎么划分到多个进程中?

带着这三个问题,我们来从头看一遍这份代码。

这份代码要拟合一个恒等映射y=x。使用的数据集非常简单,只有[1, 2, 3, 4]四个数字。

1
2
3
4
5
6
7
8
9
10
11
class MyDataset(Dataset):

def __init__(self):
super().__init__()
self.data = torch.tensor([1, 2, 3, 4], dtype=torch.float32)

def __len__(self):
return len(self.data)

def __getitem__(self, index):
return self.data[index:index + 1]

模型也只有一个线性函数:

1
2
3
4
5
6
7
8
class ToyModel(nn.Module):

def __init__(self) -> None:
super().__init__()
self.layer = nn.Linear(1, 1)

def forward(self, x):
return self.layer(x)

为了并行训练这个模型,我们要开启多进程。PyTorch提供的torchrun命令以及一些API封装了多进程的实现。我们只要在普通单进程程序前后加入以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
def setup():
dist.init_process_group('nccl')

def main():
setup()

...

cleanup()

def cleanup():
dist.destroy_process_group()

再用torchrun --nproc_per_node=GPU_COUNT main.py去跑这个脚本,就能用GPU_COUNT个进程来运行这个程序,每个进程分配一个GPU。我们可以用dist.get_rank()来查看当前进程的GPU号。同时,我们也可以验证,不同的GPU号对应了不同的进程id。

1
2
3
4
5
6
7
def main():
setup()
rank = dist.get_rank()
pid = os.getpid()
print(f'current pid: {pid}')
print(f'Current rank {rank}')
device_id = rank % torch.cuda.device_count()
text
1
2
3
4
5
Output:
current pid: 3592707
Current rank 1
current pid: 3592706
Current rank 0

接下来,我们来解决数据并行的问题。我们要确保一个epoch的数据被分配到了不同的进程上,以实现batch size的扩大。在PyTorch中,只要在生成Dataloader时把DistributedSampler的实例传入sampler参数就行了。DistributedSampler会自动对数据采样,并放到不同的进程中。我们稍后可以看到数据的采样结果。

1
2
3
dataset = MyDataset()
sampler = DistributedSampler(dataset)
dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)

接下来来看模型并行是怎么实现的。在这种并行训练方式下,每个模型使用同一份参数。在训练时,各个进程并行;在梯度下降时,各个进程会同步一次,保证每个进程的模型都更新相同的梯度。PyTorch又帮我们封装好了这些细节。我们只需要在现有模型上套一层DistributedDataParallel,就可以让模型在后续backward的时候自动同步梯度了。其他的操作都照旧,把新模型ddp_model当成旧模型model调用就行。

1
2
3
4
model = ToyModel().to(device_id)
ddp_model = DistributedDataParallel(model, device_ids=[device_id])
loss_fn = nn.MSELoss()
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)

准备好了一切后,就可以开始训练了:

1
2
3
4
5
6
7
8
9
10
for epoch in range(2):
sampler.set_epoch(epoch)
for x in dataloader:
print(f'epoch {epoch}, rank {rank} data: {x}')
x = x.to(device_id)
y = ddp_model(x)
optimizer.zero_grad()
loss = loss_fn(x, y)
loss.backward()
optimizer.step()

sampler自动完成了打乱数据集的作用。因此,在定义DataLoader时,不用开启shuffle选项。而在每个新epoch中,要用sampler.set_epoch(epoch)更新sampler,重新打乱数据集。通过输出也可以看出,数据集确实被打乱了。

text
1
2
3
4
5
6
7
8
9
Output:
epoch 0, rank 0 data: tensor([[1.],
[4.]])
epoch 0, rank 1 data: tensor([[2.],
[3.]])
epoch 1, rank 0 data: tensor([[2.],
[3.]])
epoch 1, rank 1 data: tensor([[4.],
[1.]])

大家可以去掉这行代码,跑一遍脚本,看看这行代码的作用。如果没有这行代码,每轮的数据分配情况都是一样的。

1
2
3
4
5
6
7
8
epoch 0, rank 1 data: tensor([[2.],
[3.]])
epoch 0, rank 0 data: tensor([[1.],
[4.]])
epoch 1, rank 1 data: tensor([[2.],
[3.]])
epoch 1, rank 0 data: tensor([[1.],
[4.]])

其他的训练代码和单进程代码一模一样,我们不需要做任何修改。

训练完模型后,应该保存模型。由于每个进程的模型都是一样的,我们只需要让一个进程来保存模型即可。注意,在保存模型时,其他进程不要去修改模型参数。这里最好加上一行dist.barrier(),它可以用来同步进程的运行状态。只有0号GPU的进程存完了模型,所有模型再进行下一步操作。

1
2
3
4
if rank == 0:
torch.save(ddp_model.state_dict(), ckpt_path)

dist.barrier()

读取时需要注意一下。模型存储参数时会保存参数所在设备。由于我们只用了0号GPU的进程存模型,所有参数的device都是cuda:0。而读取模型时,每个设备上的模型都要去读一次模型,参数的位置要做一个调整。

1
2
3
4
map_location = {'cuda:0': f'cuda:{device_id}'}
state_dict = torch.load(ckpt_path, map_location=map_location)
print(f'rank {rank}: {state_dict}')
ddp_model.load_state_dict(state_dict)

从输出中可以看出,在不同的进程中,参数字典是不一样的:

1
2
rank 0: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:0')), ('module.layer.bias', tensor([0.6403], device='cuda:0'))])
rank 1: OrderedDict([('module.layer.weight', tensor([[0.3840]], device='cuda:1')), ('module.layer.bias', tensor([0.6403], device='cuda:1'))])

这里还有一个重要的细节。使用DistributedDataParallelmodel封装成ddp_model后,模型的参数名里多了一个module。这是因为原来的模型model被保存到了ddp_model.module这个成员变量中(model == ddp_model.module)。在混用单GPU和多GPU的训练代码时,要注意这个参数名不兼容的问题。最好的写法是每次存取ddp_model.module,这样单GPU和多GPU的checkpoint可以轻松兼容。

到此,我们完成了一个极简的PyTorch并行训练Demo。从代码中能看出,PyTorch的封装非常到位,我们只需要在单进程代码上稍作修改,就能开启并行训练。最后,我再来总结一下单卡训练转换成并行训练的修改处:

  1. 程序开始时执行dist.init_process_group('nccl'),结束时执行dist.destroy_process_group()
  2. torchrun --nproc_per_node=GPU_COUNT main.py运行脚本。
  3. 进程初始化后用rank = dist.get_rank()获取当前的GPU ID,把模型和数据都放到这个GPU上。
  4. 封装一下模型
    1
    ddp_model = DistributedDataParallel(model, device_ids=[device_id])
  5. 封装一下DataLoader
    1
    2
    3
    dataset = MyDataset()
    sampler = DistributedSampler(dataset)
    dataloader = DataLoader(dataset, batch_size=2, sampler=sampler)
  6. 训练时打乱数据。sampler.set_epoch(epoch)
  7. 保存只在单卡上进行。
    1
    2
    3
    if rank == 0:
    torch.save(ddp_model.state_dict(), ckpt_path)
    dist.barrier()
  8. 读取数据时注意map_location,也要注意参数名里的module
    1
    2
    3
    map_location = {'cuda:0': f'cuda:{device_id}'}
    state_dict = torch.load(ckpt_path, map_location=map_location)
    ddp_model.load_state_dict(state_dict)

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

参考资料:

  1. 官方教程:https://pytorch.org/tutorials/intermediate/ddp_tutorial.html
  2. 另一个展示简单Demo的文章:https://zhuanlan.zhihu.com/p/350301395

变分自编码器(VAE)是一类常见的生成模型。纯VAE的生成效果不见得是最好的,但VAE还是经常会被用作大模型的子模块。即使是在VAE发明多年的今天,学习VAE还是很有必要的。相比GAN等更符合直觉的模型,彻底理解VAE对数学的要求较高。在这篇文章中,我会从计算机科学的角度出发,简明地讲清楚VAE的核心原理,并附上代码实现的介绍。同时,我会稍微提及VAE是怎么利用数学知识的,以及该怎么去拓展了解这些数学知识。

用自编码器生成图像

在正式开始学习VAE之前,我们先探讨一下内容生成的几种方式,并引入自编码器(Autoencoder, AE)这个概念。为了方面描述,我们仅讨论图像的生成。

在设计生成图像的程序之前,我们要考虑一个问题——程序的输入是什么?如果程序没有任何输入,那么它就应该有一个确定的输出,也就是只能画出一幅图片。而只能画出一幅图片的程序没有任何意义的。因此,一个图像生成模型一定要有输入,用于区分不同的图片。哪怕这种输入仅仅是0, 1, 2这种序号也可以,只要模型能看懂输入,为每个输入生成不同的图片就行了。

可是,我们不仅希望不同的输入能区分不同的图片,还要让相近的输入生成相近的图片。比如1.5号图片应该长得和1号和2号相似。为了让模型满足这种性质,我们可以干脆把模型的输入建模成有意义的高维实数向量。这个向量,可以是看成对图像的一种压缩编码。比如(170, 1)就表示一幅身高为170cm的男性的照片。

绝大多数生成模型都是用这种方式对生成过程建模。所有的输入向量$z$来自于一个标准正态分布$Z$。图像生成,就是把图像的编码向量$z$解码成一幅图像的过程。不同的生成模型,只是对这个过程有着不同的约束方式。

自编码器的约束方式十分巧妙:既然把$z$翻译回图像是一个解码的过程,为什么不可以把编码的过程也加进来,让整个过程自动学习呢?如下图所示,我们可以让一个模型(编码器)学会怎么把图片压缩成一个编码,再让另一个模型(解码器)学会怎么把编码解压缩成一幅图片,最小化生成图片与原图片之间的误差。

最后,解码器就是我们需要的生成模型。只要在标准多元正态分布里采样出$z$,就可生成图片了。另外,理想情况下,$z$之间的插值向量也能代表在语义上插值的图片。

可是,由于自编码器本身的限制,这种理想不一定能实现。

自编码器的问题——过拟合

自编码器的信息压缩能力十分强大。只要编码器和解码器的神经网络足够复杂,所有训练集里的图像都可以被压缩成非常短的编码。这种编码短到什么程度了呢?——只要一个一维向量(实数)就可以描述所有训练集里的图像了。

想做到这一点并不难。还记得我们开头对生成模型的输入的讨论吗?只要让模型把所有图片以数组的形式存到编码器和解码器里,以0, 1, 2这样的序号来表示每一幅训练集里的图片,就能完成最极致的信息压缩。当然,使用这种方式的话,编码$z$就失去了所有的语义信息,编码之间的插值也不能表示图像语义上的插值了。

这是由模型过拟合导致的。如果仅使用自编码器本身的约束方式,而不加入其他正则化方法的话,一定会出现过拟合。

VAE——一种正则化的自编码器

VAE就是一种使用了某种正则化方法的自编码器,它解决了上述的过拟合问题。VAE使用的这种方法来自于概率论的变分推理,不过,我们可以在完全不了解变分推理的前提下看懂VAE。

VAE的想法是这样的:我们最终希望得到一个分布$Z$,或者说一条连续的直线。可是,编码器每次只能把图片编码成一个向量,也就是一个点。很多点是很难重建出一条连续的直线的。既然如此,我们可以把每张图片也编码成一个分布。多条直线,就可以比较容易地拼成我们想要的直线了。

当然,只让模型去拟合分布是不够的。如果各个分布都乱七八糟,相距甚远,那么它们怎么都拼不成一个标准正态分布。因此,我们还需要加上一个约束,让各个分布和标准正态分布尽可能相似。

这样,我们可以总结一下VAE的训练框架。VAE依然使用了编码器-解码器的架构。只不过,编码器的输出是一个可学习的正态分布。对分布是不可能做求导和梯度下降的,但我们可以去分布里采样,对采样出来的编码$z$解码并求导。

另外,VAE的损失函数除了要最小化重建图像与原图像之间的均方误差外,还要最大化每个分布和标准正态分布之间的相似度。

常见的描述分布之间相似度的指标叫做KL散度。只要把KL散度的公式套进损失函数里,整个训练框架就算搭好了。

如果你对KL散度的原理感兴趣,欢迎阅读我的上一篇文章:从零理解熵、交叉熵、KL散度

VAE的原理其实就是这么简单。总结一下,VAE本身是一个编码器-解码器结构的自编码器,只不过编码器的输出是一个分布,而解码器的输入是该分布的一个样本。另外,在损失函数中,除了要让重建图像和原图像更接近以外,还要让输出的分布和标准正态分布更加接近。

VAE 与变分推理

前几段其实只对VAE做了一个直觉上的描述,VAE的损失函数实际上是经严谨的数学推导得到的。如果你对数学知识不感兴趣,完全可以跳过这一节的讲解。当然,这一节也只会简单地描述VAE和变分推理的关系,更详细的数学推导可以去参考网上的其他文章。

让我们从概率论的角度看待生成模型。生成模型中的$z$可以看成是隐变量,它决定了能观测到的变量$x$。比如说,袋子里有黑球和白球,你不断地从袋子里取球出来再放回去,就能够统计出抽到黑球和白球的频率。然而,真正决定这个频率的,是袋子里黑球和白球的数量,这些数量就是观测不到的隐变量。简单来说,隐变量$z$是因,变量$x$是果。

生成模型,其实就是假设$z$来自标准正态分布,想要拟合分布$P(x|z)$(解码器),以得到$x$的分布(图像分布)。为了训练解码器,自编码器架构使用了一个编码器以描述$P(z|x)$。这样,从训练集里采样,等于是采样出了一个$x$。根据$P(z|x)$求出一个$z$,再根据$P(x|z)$试图重建$x$。优化这个过程,就是在优化编码器和解码器,也就是优化$P(z|x)$和$P(x|z)$。

然而,$P(z|x)$和$P(x|z)$之间有一个约束,它们必须满足贝叶斯公式:

假如我们要用一个和$x$有关的关于$z$的分布$Q_x(z)$去拟合$P(z|x)$,就要让$Q_x(z)$和$\frac{P(x|z)P(z)}{P(x)}$这两个分布尽可能相似。如果这个相似度是KL散度,经过一系列的推导,就可以推导出我们在VAE里使用的那个损失函数。

简单来说,拟合一个未知分布的技术就叫做变分推理。VAE利用变分推理,对模型的编码器和解码器加了一个约束,这个约束在化简后就是VAE的损失函数。

VAE和变分推理的关系就是这样。如果还想细究,可以去先学习KL散度相关的知识,再去看一下VAE中KL散度的公式推导。当然,不懂这些概念并不影响VAE的学习。

总结

VAE其实就是一个编码器-解码器架构,和U-Net以及部分NLP模型类似。然而,为了抑制自编码过程中的过拟合,VAE编码器的输出是一个正态分布,而不是一个具体的编码。同时,VAE的损失函数除了约束重建图像外,还约束了生成的分布。在这些改进下,VAE能够顺利地训练出一个解码器,以把来自正态分布的随机变量$z$画成一幅图像。

如果你想通过代码实践进一步加深对VAE的理解,可以阅读附录。

参考资料

  1. 一篇不错的VAE讲解。我是跟着这篇文章学习的。https://towardsdatascience.com/understanding-variational-autoencoders-vaes-f70510919f73
  2. 我的VAE PyTorch实现参考了这个仓库:https://github.com/AntixK/PyTorch-VAE 。开头的人脸生成效果图是从这个项目里摘抄过来的。

VAE PyTorch 实现

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

数据集

在这个项目中,我使用了CelebA数据集。这个数据集有200k张人脸,裁剪和对齐后的图片只有1个多G,对实验非常友好。

CelebA的下载链接可以在官方网站上找到:https://mmlab.ie.cuhk.edu.hk/projects/CelebA.html。

下载好了图片后,可以用下面的代码创建Dataloader。

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
import os

import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms


class CelebADataset(Dataset):
def __init__(self, root, img_shape=(64, 64)) -> None:
super().__init__()
self.root = root
self.img_shape = img_shape
self.filenames = sorted(os.listdir(root))

def __len__(self) -> int:
return len(self.filenames)

def __getitem__(self, index: int):
path = os.path.join(self.root, self.filenames[index])
img = Image.open(path).convert('RGB')
pipeline = transforms.Compose([
transforms.CenterCrop(168),
transforms.Resize(self.img_shape),
transforms.ToTensor()
])
return pipeline(img)


def get_dataloader(root='data/celebA/img_align_celeba', **kwargs):
dataset = CelebADataset(root, **kwargs)
return DataLoader(dataset, 16, shuffle=True)

这段代码是一段非常常规的根据图片路径读取图片的代码。只有少数地方需要说明:

  • 为了尽快完成demo,所有人脸图片的分辨率都是$64 \times 64$。
  • CelebA里裁剪后的人脸图片是长方形的。要先调用CenterCrop裁剪出正方形人脸,再做Resize。

为了验证Dataloader的正确性,我们可以写一些脚本来查看Dataloader里的一个batch的图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
if __name__ == '__main__':
dataloader = get_dataloader()
img = next(iter(dataloader))
print(img.shape)
# Concat 4x4 images
N, C, H, W = img.shape
assert N == 16
img = torch.permute(img, (1, 0, 2, 3))
img = torch.reshape(img, (C, 4, 4 * H, W))
img = torch.permute(img, (0, 2, 1, 3))
img = torch.reshape(img, (C, 4 * H, 4 * W))
img = transforms.ToPILImage()(img)
img.save('work_dirs/tmp.jpg')

这段代码使用了一些小技巧。首先,next(iter(dataloader))可以访问Dataloader的第一个数据。其次,在把一个batch的图片转换成图片方格的过程中,我使用了比较骚的换维度、换形状操作,看起来很帅。

模型

我的VAE模型使用了类似U-Net的操作:编码器用卷积把图像的边长减半,通道翻倍,解码器用反卷积把图像的边长翻倍,通道减半。

模型结构的定义函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import torch
import torch.nn as nn


class VAE(nn.Module):
'''
VAE for 64x64 face generation. The hidden dimensions can be tuned.
'''
def __init__(self, hiddens=[16, 32, 64, 128, 256], latent_dim=128) -> None:
super().__init__()

# encoder
prev_channels = 3
modules = []
img_length = 64
for cur_channels in hiddens:
modules.append(
nn.Sequential(
nn.Conv2d(prev_channels,
cur_channels,
kernel_size=3,
stride=2,
padding=1), nn.BatchNorm2d(cur_channels),
nn.ReLU()))
prev_channels = cur_channels
img_length //= 2
self.encoder = nn.Sequential(*modules)
self.mean_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.var_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.latent_dim = latent_dim
# decoder
modules = []
self.decoder_projection = nn.Linear(
latent_dim, prev_channels * img_length * img_length)
self.decoder_input_chw = (prev_channels, img_length, img_length)
for i in range(len(hiddens) - 1, 0, -1):
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[i],
hiddens[i - 1],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[i - 1]), nn.ReLU()))
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[0],
hiddens[0],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[0]), nn.ReLU(),
nn.Conv2d(hiddens[0], 3, kernel_size=3, stride=1, padding=1),
nn.ReLU()))
self.decoder = nn.Sequential(*modules)

首先来看编码器的部分。每个卷积模块由卷积、BN、ReLU构成。卷完了再用两个全连接层分别生成正态分布的均值和方差。注意,卷积完成后,图像的形状是[prev_channels, img_length, img_length],为了把它输入到全连接层,我们到时候会做一个flatten操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# encoder
prev_channels = 3
modules = []
img_length = 64
for cur_channels in hiddens:
modules.append(
nn.Sequential(
nn.Conv2d(prev_channels,
cur_channels,
kernel_size=3,
stride=2,
padding=1), nn.BatchNorm2d(cur_channels),
nn.ReLU()))
prev_channels = cur_channels
img_length //= 2
self.encoder = nn.Sequential(*modules)
self.mean_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.var_linear = nn.Linear(prev_channels * img_length * img_length,
latent_dim)
self.latent_dim = latent_dim

解码器和编码器的操作基本完全相反。由于隐变量的维度是latent_dim,需要再用一个全连接层把图像的维度投影回[prev_channels, img_length, img_length]。之后就是反卷积放大图像的过程。写这些代码时一定要算好图像的边长,定好反卷积的次数,并且不要忘记最后把图像的通道数转换回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
# decoder
modules = []
self.decoder_projection = nn.Linear(
latent_dim, prev_channels * img_length * img_length)
self.decoder_input_chw = (prev_channels, img_length, img_length)
for i in range(len(hiddens) - 1, 0, -1):
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[i],
hiddens[i - 1],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[i - 1]), nn.ReLU()))
modules.append(
nn.Sequential(
nn.ConvTranspose2d(hiddens[0],
hiddens[0],
kernel_size=3,
stride=2,
padding=1,
output_padding=1),
nn.BatchNorm2d(hiddens[0]), nn.ReLU(),
nn.Conv2d(hiddens[0], 3, kernel_size=3, stride=1, padding=1),
nn.ReLU()))
self.decoder = nn.Sequential(*modules)

网络前向传播的过程如正文所述,先是用编码器编码,把图像压平送进全连接层得到均值和方差,再用randn_like随机采样,把采样的z投影、变换成正确的维度,送入解码器,最后输出重建图像以及正态分布的均值和方差。

1
2
3
4
5
6
7
8
9
10
11
12
13
def forward(self, x):
encoded = self.encoder(x)
encoded = torch.flatten(encoded, 1)
mean = self.mean_linear(encoded)
logvar = self.var_linear(encoded)
eps = torch.randn_like(logvar)
std = torch.exp(logvar / 2)
z = eps * std + mean
x = self.decoder_projection(z)
x = torch.reshape(x, (-1, *self.decoder_input_chw))
decoded = self.decoder(x)

return decoded, mean, logvar

用该模型随机生成图像的过程和前向传播的过程十分类似,只不过$z$来自于标准正态分布而已,解码过程是一模一样的。

1
2
3
4
5
6
def sample(self, device='cuda'):
z = torch.randn(1, self.latent_dim).to(device)
x = self.decoder_projection(z)
x = torch.reshape(x, (-1, *self.decoder_input_chw))
decoded = self.decoder(x)
return decoded

主函数

在主函数中,我们要先完成模型训练。在训练前,还有一件重要的事情要做:定义损失函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from time import time

import torch
import torch.nn.functional as F
from torchvision.transforms import ToPILImage

from dldemos.VAE.load_celebA import get_dataloader
from dldemos.VAE.model import VAE

# Hyperparameters
n_epochs = 10
kl_weight = 0.00025
lr = 0.005


def loss_fn(y, y_hat, mean, logvar):
recons_loss = F.mse_loss(y_hat, y)
kl_loss = torch.mean(
-0.5 * torch.sum(1 + logvar - mean**2 - torch.exp(logvar), 1), 0)
loss = recons_loss + kl_loss * kl_weight
return loss

如正文所述,VAE的loss包括两部分:图像的重建误差和分布之间的KL散度。二者的比例可以通过kl_weight来控制。

KL散度的公式直接去网上照抄即可。

这里要解释一下,我们的方差为什么使用其自然对数logvar。经过我的实验,如果让模型输出方差本身的话,就要在损失函数里对齐取一次自然对数。如果方差很小,趋于0的话,方差的对数就趋于无穷。这表现在loss里会出现nan。因此,在神经网络中我们应该避免拟合要取对数的数,而是直接去拟合其对数运算结果。

准备好了损失函数,剩下就是常规的训练操作了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def train(device, dataloader, model):
optimizer = torch.optim.Adam(model.parameters(), lr)
dataset_len = len(dataloader.dataset)

begin_time = time()
# train
for i in range(n_epochs):
loss_sum = 0
for x in dataloader:
x = x.to(device)
y_hat, mean, logvar = model(x)
loss = loss_fn(x, y_hat, mean, logvar)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss
loss_sum /= dataset_len
training_time = time() - begin_time
minute = int(training_time // 60)
second = int(training_time % 60)
print(f'epoch {i}: loss {loss_sum} {minute}:{second}')
torch.save(model.state_dict(), 'dldemos/VAE/model.pth')

训练好模型后,想要查看模型重建数据集图片的效果也很简单,去dataloader里采样、跑模型、后处理结果即可。

1
2
3
4
5
6
7
8
9
10
def reconstruct(device, dataloader, model):
model.eval()
batch = next(iter(dataloader))
x = batch[0:1, ...].to(device)
output = model(x)[0]
output = output[0].detach().cpu()
input = batch[0].detach().cpu()
combined = torch.cat((output, input), 1)
img = ToPILImage()(combined)
img.save('work_dirs/tmp.jpg')

想用模型随机生成图片的话,可以利用之前写好的模型采样函数。

1
2
3
4
5
6
def generate(device, model):
model.eval()
output = model.sample(device)
output = output[0].detach().cpu()
img = ToPILImage()(output)
img.save('work_dirs/tmp.jpg')

在3090上跑这个实验,100个epoch需要5个多小时。但是,模型差不多在10多个epoch的时候就收敛了。

最朴素的VAE的重建效果并不是很好,只能大概看出个脸型。这可能也和我的模型参数较少有关。

随机生成的图片也是形状还可以,但非常模糊。

如今,人脸风格迁移的效果越来越惊人。给定一张人脸照片,不管是变成卡通风格、二次元风格,甚至是变成讽刺画风格,AI都能轻轻松松做到。

AI能有这么强的生成效果,都得归功于创作力极强的StyleGAN。StyleGAN本身只是用来随机生成人脸的模型。随着研究的不断推进,StyleGAN被应用到了人脸风格迁移任务中。在这篇文章里,我将简要介绍一下基于StyleGAN的人脸风格迁移的最新pipeline。

知识准备

风格迁移

在深度CNN展示出了强大的图像识别能力后,人们产生了疑问:CNN是感知图像的呢?为此,研究者找出了令深度CNN各层输出值最大的输入图片。这些图片能表明CNN各层“看到了”什么。实验结果显示,浅层网络关注轮廓信息,而深层网络会关注更抽象的信息。

受此启发,有人提出了一种叫做神经网络风格迁移[1]的应用——输入一张内容图像(C)和一张风格图像(S),输出一幅融合内容与风格的图片(G)。这一应用是通过对齐图像在深度CNN中各层的输出结果实现的。

风格迁移的具体介绍可以参见我之前的文章:Neural Style Transfer 风格迁移经典论文讲解

StyleGAN

GAN能够根据一个高维向量$z$生成图像($z$可以看成图像的身份证号)。但是,早期的图像GAN不能对图像生成的过程加以干预。

对此,人们提出了可控的图像生成网络StyleGAN[2]。在这个模型生成完一幅图像后,可以对图片进行由粗至精共18种微调:对于人脸生成而言,既可以调整性别、年龄这种更宏观的属性,也可以调整肤色、发色这种更具体的属性。

StyleGAN 的详细介绍可以参见我之前的文章:用18支画笔作画的AI ~ StyleGAN特点浅析

基于 image-to-image 的风格迁移

在计算机眼中,无论是自然语言,还是图像,都是一堆数据。就像不同的语言之间可以翻译一样,不同风格之间也可以翻译。风格迁移任务,其实就可以看成把来自某一种风格的图像翻译到另一种风格中。早期的人脸风格迁移,都是通过图像翻译(image-to-image translation)框架实现的。

对于语言翻译,需要准备好成对数据的数据集。比如在中文到英文翻译任务中,要给每一个中文句子准备其对应的英文翻译。而在风格迁移任务中,获得成对数据是几乎不可能的:哪有画家会去费心地给上千张人脸绘制出对应的漫画脸呢?因此,在风格迁移中,图像翻译都是通过无监督方式训练的,即不去使用成对的数据。

无监督的图像翻译一般也是通过GAN实现。生成器的输入是普通的人脸照片,输出是其对应的风格人脸照片;判别器的输入是生成的假图或者风格数据集里的真图,输出为该图片是否是风格图片。受到StyleGAN的启发,风格迁移的图像翻译网络还会使用类似的“风格模块”,以提升风格迁移的效果。下图是近期的图像翻译风格迁移工作 U-GAT-IT[3] 的网络示意图。

用图像翻译的想法来做风格迁移确实可行。但是,换一种新风格,就得花费大量精力去训练一个GAN,这一流程十分繁琐。

利用预训练 StyleGAN 的 Toonify

在StyleGAN面世后,热心的研究者们对其展开了各个角度的拓展。其中,有一篇短小精悍的文章用一种十分简单的方法让StyleGAN完成了风格迁移。这篇工作后来被称为Toonify[4]。

先来看一下Toonify的使用效果。Toonify使用了两个StyleGAN,一个能够生成真实的人脸,一个能够生成风格化人脸。对于同一个高维向量输入$z$,只需要对两个StyleGAN的权重进行插值,就能让$z$对应的两幅图像之间发生风格迁移。下图中,a是真实StyleGAN的输出,b是风格化StyleGAN的输出,c, d是把两幅图像的内容和风格交换后的输出,e是精心组合两个StyleGAN权重得到的输出。

Toonify使用的权重插值方法非常直接。StyleGAN生成图像时,会先生成分辨率较小的图片,再逐渐把图片放大。修改小分辨率处的权重,会改变图像的内容;修改大分辨率处的权重,会改变图像的风格。Toonify做的插值,仅仅是交换两个StyleGAN不同分辨率处的权重而已。

以上只是Toonify的核心思想。要利用Toonify做风格迁移,还有两个问题要解决:

  1. 怎么根据真实StyleGAN得到风格化的StyleGAN?
  2. StyleGAN的图片是通过$z$生成出来的。对于任意一幅真实人脸图像,怎么得到它的$z$呢?

对于第一个问题,风格化的StyleGAN是通过微调得到的。作者用预训练的真实StyleGAN在新的风格化数据集上微调出了另一个StyleGAN。

对于第二个问题,任意一张真实人脸图片的$z$可以通过简单的数学优化来实现:给定一张图片,我们要求一个$z$,使得$z$通过真实StyleGAN的输出和输入图片尽可能相似。当然,这种直接优化得到的$z$不一定是最优的。把任何一张图片嵌入进StyleGAN的隐空间是一个大的研究方向,叫做GAN Inversion.

总结下来,Toonify使用的pipeline如下:

  1. 在新风格上微调StyleGAN。
  2. 通过GAN Inversion得到真实人脸的$z$。
  3. 交换新旧StyleGAN的权重,得到插值StyleGAN。$z$在插值StyleGAN中的输出就是风格迁移后的图片。

基于 StyleGAN 的风格迁移

Toonify这篇工作本身非常简短,但它却为风格迁移指出了一条新的路线。相比基于图像翻译的风格迁移,这种新方法有以下好处:

  1. 训练代价大幅减小。新方法可以直接使用预训练好的StyleGAN,在几百幅新风格的图像上微调即可完成主要的模型训练,而无需从头训练一个GAN。
  2. 可以直接利用StyleGAN的强大图像生成能力,甚至是StyleGAN的图像编辑能力(修改低分辨率或高分辨率的输入,以改变图像的内容和风格)。

以发表在图形学顶刊ACM TOG上的AgileGAN[5]为例,我们来简单地看一下前沿的风格迁移框架是怎样的。

模型训练分两部分,先训练编码器(完成GAN Inversion),再训练解码器(生成图像)。作者用VAE来建模编码器的训练过程,此时的解码器是固定权重的预训练StyleGAN。基于VAE的编码器训练好了之后,再固定编码器的权重,把预训练的StyleGAN在新数据集上微调,得到风格化的StyleGAN。

在推理时,输入一张图片,VAE输出的分布的均值即是该图片的编码$z$。把$z$输入进微调后的StyleGAN,即可得到风格迁移的结果。

由于使用了预训练StyleGAN,整个训练过程能在一小时内完成。

作者还精心优化了StyleGAN的结构。StyleGAN的浅层决定了生成图像的抽象属性,比如性别、年龄。通过修改浅层的权重,即可得到不同性别、年龄的人脸图像。作者在StyleGAN的浅层网络中加入了多条路径(多条路径其实等价于多个网络),一条路径固定了一种人脸属性。比如第一条路径只能生成男性,第二条路径只能生成女性。每一条路径都有一个专门的判别器,该判别器集成了StyleGAN本身的预训练判别器和一个判断属性的判别器,保证一条路径生成的图像都满足某属性。

从这个例子中,我们能够直观地认识到微调StyleGAN的风格迁移方法的优势。训练上的便捷性、StyleGAN强大的生成能力与编辑能力使得这一方法在众多风格迁移方法中脱颖而出。

结语

在这篇文章中,我简要介绍了一种前沿的风格迁移pipeline,并讲述了它是如何一步一步诞生的。从本文提及的前沿工作中也能看出,当前风格迁移的效果非常好,训练起来也不难,可以用其轻松地开发出人脸年轻化、性别转换、二次元化等等有趣的应用。如果你对相关的研究或应用感兴趣,欢迎参考这篇文章提及的文献进行学习。

如果你很细心,会发现这篇文章开头展示的图片并不是本文介绍的任何一篇论文的输出结果。实际上,它是一篇更新的工作——DualStyleGAN——的输出结果。这篇工作的原理更加复杂,我会在之后的文章里对其做介绍。

参考文献

[1] Image style transfer using convolutional neural networks

[2] A Style-Based Generator Architecture for Generative Adversarial Networks

[3] U-GAT-IT: Unsupervised Generative Attentional Networks with Adaptive Layer-Instance Normalization for Image-to-Image Translation

[4] Resolution Dependent GAN Interpolation for
Controllable Image Synthesis Between Domains

[5] AgileGAN: Stylizing Portraits by Inversion-Consistent Transfer Learning

Attention Is All You Need (Transformer) 是当今深度学习初学者必读的一篇论文。但是,这篇工作当时主要是用于解决机器翻译问题,有一定的写作背景,对没有相关背景知识的初学者来说十分难读懂。在这篇文章里,我将先补充背景知识,再清晰地解读一下这篇论文,保证让大多数对深度学习仅有少量基础的读者也能彻底读懂这篇论文。

知识准备

机器翻译,就是将某种语言的一段文字翻译成另一段文字。

由于翻译没有唯一的正确答案,用准确率来衡量一个机器翻译算法并不合适。因此,机器翻译的数据集通常会为每一条输入准备若干个参考输出。统计算法输出和参考输出之间的重复程度,就能评价算法输出的好坏了。这种评价指标叫做BLEU Score。这一指标越高越好。

在深度学习时代早期,人们使用RNN(循环神经网络)来处理机器翻译任务。一段输入先是会被预处理成一个token序列。RNN会对每个token逐个做计算,并维护一个表示整段文字整体信息的状态。根据当前时刻的状态,RNN可以输出当前时刻的一个token。

所谓token,既可以是一个单词、一个汉字,也可能是一个表示空白字符、未知字符、句首字符的特殊字符。

具体来说,在第$t$轮计算中,输入是上一轮的状态$a^{< t - 1 >}$以及这一轮的输入token $x ^{< t >}$,输出这一轮的状态$a^{< t >}$以及这一轮的输出token $y ^{< t >}$。

这种简单的RNN架构仅适用于输入和输出等长的任务。然而,大多数情况下,机器翻译的输出和输入都不是等长的。因此,人们使用了一种新的架构。前半部分的RNN只有输入,后半部分的RNN只有输出(上一轮的输出会当作下一轮的输入以补充信息)。两个部分通过一个状态$a^{< T_x >}$来传递信息。把该状态看成输入信息的一种编码的话,前半部分可以叫做“编码器”,后半部分可以叫做“解码器”。这种架构因而被称为“编码器-解码器”架构。

这种架构存在不足:编码器和解码器之间只通过一个隐状态来传递信息。在处理较长的文章时,这种架构的表现不够理想。为此,有人提出了基于注意力的架构。这种架构依然使用了编码器和解码器,只不过解码器的输入是编码器的状态的加权和,而不再是一个简单的中间状态。每一个输出对每一个输入的权重叫做注意力,注意力的大小取决于输出和输入的相关关系。这种架构优化了编码器和解码器之间的信息交流方式,在处理长文章时更加有效。

尽管注意力模型的表现已经足够优秀,但所有基于RNN的模型都面临着同样一个问题:RNN本轮的输入状态取决于上一轮的输出状态,这使RNN的计算必须串行执行。因此,RNN的训练通常比较缓慢。

在这一背景下,抛弃RNN,只使用注意力机制的Transformer横空出世了。

摘要与引言

补充完了背景知识,文章就读起来比较轻松了。

摘要传递的信息非常简练:

  • 当前最好的架构是基于注意力的”encoder-decoder”架构。这些架构都使用了CNN或RNN。这篇文章提出的Transformer架构仅使用了注意力机制,而无需使用CNN和RNN。

  • 两项机器翻译的实验表明,这种架构不仅精度高,而且训练时间大幅缩短。

摘要并没有解释Transformer的设计动机。让我们在引言中一探究竟。

引言的第一段回顾了RNN架构。以LSTM和GRU为代表的RNN在多项序列任务中取得顶尖的成果。许多研究仍在拓宽循环语言模型和”encoder-decoder”架构的能力边界。

第二段就开始讲RNN的不足了。RNN要维护一个隐状态,该隐状态取决于上一时刻的隐状态。这种内在的串行计算特质阻碍了训练时的并行计算(特别是训练序列较长时,每一个句子占用的存储更多,batch size变小,并行度降低)。有许多研究都在尝试解决这一问题,但是,串行计算的本质是无法改变的。

上一段暗示了Transformer的第一个设计动机:提升训练的并行度。第三段讲了Transformer的另一个设计动机:注意力机制。注意力机制是当时最顶尖的模型中不可或缺的组件。这一机制可以让每对输入输出关联起来,而不用像早期使用一个隐状态传递信息的”encoder-decoder”模型一样,受到序列距离的限制。然而,几乎所有的注意力机制都用在RNN上的。

既然注意力机制能够无视序列的先后顺序,捕捉序列间的关系,为什么不只用这种机制来构造一个适用于并行计算的模型呢?因此,在这篇文章中,作者提出了Transformer架构。这一架构规避了RNN的使用,完全使用注意力机制来捕捉输入输出序列之间的依赖关系。这种架构不仅训练得更快了,表现还更强了。

通过阅读摘要和引言,我们基本理解了Transformer架构的设计动机。作者想克服RNN不能并行的缺点,又想充分利用没有串行限制的注意力机制,于是就提出了一个只有注意力机制的模型。模型训练出来了,结果出乎预料地好,不仅训练速度大幅加快,模型的表现也超过了当时所有其他模型。

接下来,我们可以直接跳到第三章学习Tranformer的结构。

注意力机制

文章在介绍Transformer的架构时,是自顶向下介绍的。但是,一开始我们并不了解Transformer的各个模块,理解整体框架时会有不少的阻碍。因此,我们可以自底向上地来学习Transformer架构。

首先,跳到3.2节,这一节介绍了Transformer里最核心的机制——注意力。在阅读这部分的文字之前,我们先抽象地理解一下注意力机制究竟是在做什么。

注意力计算的一个例子

其实,“注意力”这个名字取得非常不易于理解。这个机制应该叫做“全局信息查询”。做一次“注意力”计算,其实就跟去数据库了做了一次查询一样。假设,我们现在有这样一个以人名为key(键),以年龄为value(值)的数据库:

text
1
2
3
4
5
6
{
张三: 18,
张三: 20,
李四: 22,
张伟: 19
}

现在,我们有一个query(查询),问所有叫“张三”的人的年龄平均值是多少。让我们写程序的话,我们会把字符串“张三”和所有key做比较,找出所有“张三”的value,把这些年龄值相加,取一个平均数。这个平均数是(18+20)/2=19。

但是,很多时候,我们的查询并不是那么明确。比如,我们可能想查询一下所有姓张的人的年龄平均值。这次,我们不是去比较key == 张三,而是比较key[0] == 张。这个平均数应该是(18+20+19)/3=19。

或许,我们的查询会更模糊一点,模糊到无法用简单的判断语句来完成。因此,最通用的方法是,把query和key各建模成一个向量。之后,对query和key之间算一个相似度(比如向量内积),以这个相似度为权重,算value的加权和。这样,不管多么抽象的查询,我们都可以把query, key建模成向量,用向量相似度代替查询的判断语句,用加权和代替直接取值再求平均值。“注意力”,其实指的就是这里的权重。

把这种新方法套入刚刚那个例子里。我们先把所有key建模成向量,可能可以得到这样的一个新数据库:

text
1
2
3
4
5
6
{
[1, 2, 0]: 18, # 张三
[1, 2, 0]: 20, # 张三
[0, 0, 2]: 22, # 李四
[1, 4, 0]: 19 # 张伟
}

假设key[0]==1表示姓张。我们的查询“所有姓张的人的年龄平均值”就可以表示成向量[1, 0, 0]。用这个query和所有key算出的权重是:

1
2
3
4
dot([1, 0, 0], [1, 2, 0]) = 1
dot([1, 0, 0], [1, 2, 0]) = 1
dot([1, 0, 0], [0, 0, 2]) = 0
dot([1, 0, 0], [1, 4, 0]) = 1

之后,我们该用这些权重算平均值了。注意,算平均值时,权重的和应该是1。因此,我们可以用softmax把这些权重归一化一下,再算value的加权和。

text
1
2
softmax([1, 1, 0, 1]) = [1/3, 1/3, 0, 1/3]
dot([1/3, 1/3, 0, 1/3], [18, 20, 22, 19]) = 19

这样,我们就用向量运算代替了判断语句,完成了数据库的全局信息查询。那三个1/3,就是query对每个key的注意力。

Scaled Dot-Product Attention (3.2.1节)

我们刚刚完成的计算差不多就是Transformer里的注意力,这种计算在论文里叫做放缩点乘注意力(Scaled Dot-Product Attention)。它的公式是:

我们先来看看$Q, K, V$在刚刚那个例子里究竟是什么。$K$比较好理解,$K$其实就是key向量的数组,也就是

text
1
K = [[1, 2, 0], [1, 2, 0], [0, 0, 2], [1, 4, 0]] 

同样,$V$就是value向量的数组。而在我们刚刚那个例子里,value都是实数。实数其实也就是可以看成长度为1的向量。因此,那个例子的$V$应该是

text
1
V = [[18], [20], [22], [19]]

在刚刚那个例子里,我们只做了一次查询。因此,准确来说,我们的操作应该写成。

其中,query $q$就是[1, 0, 0]了。

实际上,我们可以一次做多组query。把所有$q$打包成矩阵$Q$,就得到了公式

等等,这个$d_k$是什么意思?$d_k$就是query和key向量的长度。由于query和key要做点乘,这两种向量的长度必须一致。value向量的长度倒是可以不一致,论文里把value向量的长度叫做$d_v$。在我们这个例子里,$d_k=3, d_v=1$。

为什么要用一个和$d_k$成比例的项来放缩$QK^T$呢?这是因为,softmax在绝对值较大的区域梯度较小,梯度下降的速度比较慢。因此,我们要让被softmax的点乘数值尽可能小。而一般在$d_k$较大时,也就是向量较长时,点乘的数值会比较大。除以一个和$d_k$相关的量能够防止点乘的值过大。

刚才也提到,$QK^T$其实是在算query和key的相似度。而算相似度并不只有求点乘这一种方式。另一种常用的注意力函数叫做加性注意力,它用一个单层神经网络来计算两个向量的相似度。相比之下,点乘注意力算起来快一些。出于性能上的考量,论文使用了点乘注意力。

自注意力

自注意力是3.2.3节里提及的内容。我认为,学完注意力的原理后,立刻去学自注意力能够更快地理解注意力机制。当然,论文里并没有对自注意力进行过多的引入,初学者学起来会非常困难。因此,这里我参考《深度学习专项》里的介绍方式,用一个更具体的例子介绍了自注意力。

大致明白了注意力机制其实就是“全局信息查询”,并掌握了注意力的公式后,我们来以Transformer的自注意力为例,进一步理解注意力的意义。

自注意力模块的目的是为每一个输入token生成一个向量表示,该表示不仅能反映token本身的性质,还能反映token在句子里特有的性质。比如翻译“简访问非洲”这句话时,第三个字“问”在中文里有很多个意思,比如询问、慰问等。我们想为它生成一个表示,知道它在句子中的具体意思。而在例句中,“问”字组词组成了“访问”,所以它应该取“询问”这个意思,而不是“慰问”。“询问”就是“问”字在这句话里的表示。

让我们看看自注意力模块具体是怎么生成这种表示的。自注意力模块的输入是3个矩阵$Q, K, V$。准确来说,这些矩阵是向量的数组,也就是每一个token的query, key, value向量构成的数组。自注意力模块会为每一个token输出一个向量表示$A$。$A^{< t >}$是第$t$个token在这句话里的向量表示。

我们先别管token的query, key, value究竟是什么算出来的,后文会对此做解释。

让我们还是以刚刚那个句子“简访问非洲”为例,看一下自注意力是怎么计算的。现在,我们想计算$A^{< 3 >}$。$A^{< 3 >}$表示的是“问”字在句子里的确切含义。为了获取$A^{< 3 >}$,我们可以问这样一个可以用数学表达的问题:“和‘问’字组词的字的词嵌入是什么?”。这个问题就是第三个token的query向量$q^{< 3 >}$。

和“问”字组词的字,很可能是一个动词。恰好,每一个token的key $k^{< t >}$就表示这个token的词性;每一个token的value $v^{< t >}$,就是这个token的嵌入。

这样,我们就可以根据每个字的词性(key),尽量去找动词(和query比较相似的key),求出权重(query和key做点乘再做softmax),对所有value求一个加权平均,就差不多能回答问题$q^{< 3 >}$了。

经计算,$q^{< 3 >}, k^{< 2 >}$可能会比较相关,即这两个向量的内积比较大。因此,最终算出来的$A^{< 3 >}$应该约等于$v^{< 2 >}$,即问题“哪个字和‘问’字组词了?”的答案是第二个字“访”。

这是$A^{< 3 >}$的计算过程。准确来说,$A^{< 3 >}=A(q^{< 3 >}, K, V)$。类似地,$A^{< 1 >}到A^{< 5 >}$都是用这个公式来计算。把所有$A$的计算合起来,把$q$合起来,得到的公式就是注意力的公式。

从上一节中,我们知道了注意力其实就是全局信息查询。而在这一节,我们知道了注意力的一种应用:通过让一句话中的每个单词去向其他单词查询信息,我们能为每一个单词生成一个更有意义的向量表示。

可是,我们还留了一个问题没有解决:每个单词的query, key, value是怎么得来的?这就要看Transformer里的另一种机制了——多头注意力。

多头注意力 (3.2.2节)

在自注意力中,每一个单词的query, key, value应该只和该单词本身有关。因此,这三个向量都应该由单词的词嵌入得到。另外,每个单词的query, key, value不应该是人工指定的,而应该是可学习的。因此,我们可以用可学习的参数来描述从词嵌入到query, key, value的变换过程。综上,自注意力的输入$Q, K, V$应该用下面这个公式计算:

其中,$E$是词嵌入矩阵,也就是每个单词的词嵌入的数组;$W^Q, W^K, W^V$是可学习的参数矩阵。在Transformer中,大部分中间向量的长度都用$d_{model}$表示,词嵌入的长度也是$d_{model}$。因此,设输入的句子长度为$n$,则$E$的形状是$n \times d_{model}$,$W^Q, W^K$的形状是$d_{model} \times d_k$,$W^V$的形状是$d_{model} \times d_v$。

就像卷积层能够用多个卷积核生成多个通道的特征一样,我们也用多组$W^Q, W^K, W^V$生成多组自注意力结果。这样,每个单词的自注意力表示会更丰富一点。这种机制就叫做多头注意力。把多头注意力用在自注意力上的公式为:

Transformer似乎默认所有向量都是行向量,参数矩阵都写成了右乘而不是常见的左乘。

其中,$h$是多头自注意力的“头”数,$W^O$是另一个参数矩阵。多头注意力模块的输入输出向量的长度都是$d_{model}$。因此,$W^O$的形状是$hd_v \times d_{model}$(自注意力的输出长度是$d_v$,有$h$个输出)。在论文中,Transfomer的默认参数配置如下:

  • $d_{model} = 512$
  • $h = 8$
  • $d_k = d_v = d_{model}/h = 64$

实际上,多头注意力机制不仅仅可以用在计算自注意力上。推广一下,如果把多头自注意力的输入$E$拆成三个矩阵$Q, K, V$,则多头注意力的公式为:

Transformer 模型架构

看懂了注意力机制,可以回过头阅读3.1节学习Transformer的整体架构了。

论文里的图1是Transformer的架构图。然而,由于我们没读后面的章节,有一些模块还没有见过。因此,我们这轮阅读的时候可以只关注模型主干,搞懂encoder和decoder之间是怎么组织起来的。

我们现在仅知道多头注意力模块的原理,对模型主干中的三个模块还有疑问:

  1. Add & Norm
  2. Feed Forward
  3. 为什么一个多头注意力前面加了Masked

我们来依次看懂这三个模块。

残差连接(3.1节)

Transformer使用了和ResNet类似的残差连接,即设模块本身的映射为$F(x)$,则模块输出为$Normalization(F(x)+x)$。和ResNet不同,Transformer使用的归一化方法是LayerNorm。

另外要注意的是,残差连接有一个要求:输入$x$和输出$F(x)+x$的维度必须等长。在Transformer中,包括所有词嵌入在内的向量长度都是$d_{model}=512$。

前馈网络

架构图中的前馈网络(Feed Forward)其实就是一个全连接网络。具体来说,这个子网络由两个线性层组成,中间用ReLU作为激活函数。

中间的隐藏层的维度数记作$d_{ff}$。默认$d_{ff}=2048$。

整体架构与掩码多头注意力

现在,我们基本能看懂模型的整体架构了。只有读懂了整个模型的运行原理,我们才能搞懂多头注意力前面的masked是哪来的。

论文第3章开头介绍了模型的运行原理。和多数强力的序列转换模型一样,Transformer使用了encoder-decoder的架构。早期基于RNN的序列转换模型在生成序列时一般会输入前$i$个单词,输出第$i+1$个单词。

而Transformer不同。对于输入序列$(x_1, …, x_s)$,它会被编码器编码成中间表示 $\mathbf{z} = (z_1, …, z_s)$。给定$\mathbf{z}$的前提下,解码器输入$(y_1, …, y_t)$,输出$(y_2, …, y_{t+1})$的预测。

Transformer 默认会并行地输出结果。而在推理时,序列必须得串行生成。直接调用Transformer的并行输出逻辑会产生非常多的冗余运算量。推理的代码实现可以进行优化。

具体来说,输入序列$x$会经过$N=6$个结构相同的层。每层由多个子层组成。第一个子层是多头注意力层,准确来说,是多头自注意力。这一层可以为每一个输入单词提取出更有意义的表示。之后数据会经过前馈网络子层。最终,输出编码结果$\mathbf{z}$。

得到了$\mathbf{z}$后,要用解码器输出结果了。解码器的输入是当前已经生成的序列,该序列会经过一个掩码(masked)多头自注意力子层。我们先不管这个掩码是什么意思,暂且把它当成普通的多头自注意力层。它的作用和编码器中的一样,用于提取出更有意义的表示。

接下来,数据还会经过一个多头注意力层。这个层比较特别,它的K,V来自$\mathbf{z}$,Q来自上一层的输出。为什么会有这样的设计呢?这种设计来自于早期的注意力模型。如下图所示,在早期的注意力模型中,每一个输出单词都会与每一个输入单词求一个注意力,以找到每一个输出单词最相关的某几个输入单词。用注意力公式来表达的话,Q就是输出单词,K, V就是输入单词。

经过第二个多头注意力层后,和编码器一样,数据会经过一个前馈网络。最终,网络并行输出各个时刻的下一个单词。

这种并行计算有一个要注意的地方。在输出第$t+1$个单词时,模型不应该提前知道$t+1$时刻之后的信息。因此,应该只保留$t$时刻之前的信息,遮住后面的输入。这可以通过添加掩码实现。添加掩码的一个不严谨的示例如下表所示:

输入 输出
(y1, y2, y3, y4) y2
(y1, y2, y3, y4) y3
(y1, y2, y3, y4) y4

这就是为什么解码器的多头自注意力层前面有一个masked。在论文中,mask是通过令注意力公式的softmax的输入为$- \infty$来实现的(softmax的输入为$- \infty$,注意力权重就几乎为0,被遮住的输出也几乎全部为0)。每个mask都是一个上三角矩阵。

嵌入层

看完了Transformer的主干结构,再来看看输入输出做了哪些前后处理。

和其他大多数序列转换任务一样,Transformer主干结构的输入输出都是词嵌入序列。词嵌入,其实就是一个把one-hot向量转换成有意义的向量的转换矩阵。在Transformer中,解码器的嵌入层和输出线性层是共享权重的——输出线性层表示的线性变换是嵌入层的逆变换,其目的是把网络输出的嵌入再转换回one-hot向量。如果某任务的输入和输出是同一种语言,那么编码器的嵌入层和解码器的嵌入层也可以共享权重。

论文中写道:“输入输出的嵌入层和softmax前的线性层共享权重”。这个描述不够清楚。如果输入和输出的不是同一种语言,比如输入中文输出英文,那么共享一个词嵌入是没有意义的。

嵌入矩阵的权重乘了一个$\sqrt{d_{model}}$。

由于模型要预测一个单词,输出的线性层后面还有一个常规的softmax操作。

位置编码

现在,Transformer的结构图还剩一个模块没有读——位置编码。无论是RNN还是CNN,都能自然地利用到序列的先后顺序这一信息。然而,Transformer的主干网络并不能利用到序列顺序信息。因此,Transformer使用了一种叫做“位置编码”的机制,对编码器和解码器的嵌入输入做了一些修改,以向模型提供序列顺序信息。

嵌入层的输出是一个向量数组,即词嵌入向量的序列。设数组的位置叫$pos$,向量的某一维叫$i$。我们为每一个向量里的每一个数添加一个实数编码,这种编码方式要满足以下性质:

  1. 对于同一个$pos$不同的$i$,即对于一个词嵌入向量的不同元素,它们的编码要各不相同。
  2. 对于向量的同一个维度处,不同$pos$的编码不同。且$pos$间要满足相对关系,即$f(pos+1) - f(pos) = f(pos) - f(pos - 1)$。

要满足这两种性质的话,我们可以轻松地设计一种编码函数:

即对于每一个位置$i$,用小数点后的3个十进制数位来表示不同的$pos$。$pos$之间也满足相对关系。

但是,这种编码不利于网络的学习。我们更希望所有编码都差不多大小,且都位于0~1之间。为此,Transformer使用了三角函数作为编码函数。这种位置编码(Positional Encoding, PE)的公式如下。

$i$不同,则三角函数的周期不同。同$pos$不同周期的三角函数值不重复。这满足上面的性质1。另外,根据三角函数的和角公式:

$f(pos + k)$ 是 $f(pos)$ 的一个线性函数,即不同的pos之间有相对关系。这满足性质2。

本文作者也尝试了用可学习的函数作为位置编码函数。实验表明,二者的表现相当。作者还是使用了三角函数作为最终的编码函数,这是因为三角函数能够外推到任意长度的输入序列,而可学习的位置编码只能适应训练时的序列长度。

为什么用自注意力

在论文的第四章,作者用自注意力层对比了循环层和卷积层,探讨了自注意力的一些优点。

自注意力层是一种和循环层和卷积层等效的计算单元。它们的目的都是把一个向量序列映射成另一个向量序列,比如说编码器把$x$映射成中间表示$z$。论文比较了三个指标:每一层的计算复杂度、串行操作的复杂度、最大路径长度。

前两个指标很容易懂,第三个指标最大路径长度需要解释一下。最大路径长度表示数据从某个位置传递到另一个位置的最大长度。比如对边长为n的图像做普通卷积操作,卷积核大小3x3,要做$n/3$次卷积才能把信息从左上角的像素传播到右下角的像素。设卷积核边长为$k$,则最大路径长度$O(n/k)$。如果是空洞卷积的话,像素第一次卷积的感受野是3x3,第二次是5x5,第三次是9x9,以此类推,感受野会指数级增长。这种卷积的最大路径长度是$O(log_k(n))$。

我们可以从这三个指标分别探讨自注意力的好处。首先看序列操作的复杂度。如引言所写,循环层最大的问题是不能并行训练,序列计算复杂度是$O(n)$。而自注意力层和卷积一样可以完全并行。

再看每一层的复杂度。设$n$是序列长度,$d$是词嵌入向量长度。其他架构的复杂度有$d^2$,而自注意力是$d$。一般模型的$d$会大于$n$,自注意力的计算复杂度也会低一些。

最后是最大路径长度。注意力本来就是全局查询操作,可以在$O(1)$的时间里完成所有元素间信息的传递。它的信息传递速度远胜卷积层和循环层。

为了降低每层的计算复杂度,可以改进自注意力层的查询方式,让每个元素查询最近的$r$个元素。本文仅提出了这一想法,并没有做相关实验。

实验与结果

本工作测试了“英语-德语”和“英语-法语”两项翻译任务。使用论文的默认模型配置,在8张P100上只需12小时就能把模型训练完。本工作使用了Adam优化器,并对学习率调度有一定的优化。模型有两种正则化方式:1)每个子层后面有Dropout,丢弃概率0.1;2)标签平滑(Label Smoothing)。Transformer在翻译任务上胜过了所有其他模型,且训练时间大幅缩短。

论文同样展示了不同配置下Transformer的消融实验结果。

实验A表明,计算量不变的前提下,需要谨慎地调节$h$和$d_k, d_v$的比例,太大太小都不好。这些实验也说明,多头注意力比单头是要好的。

实验B表明,$d_k$增加可以提升模型性能。作者认为,这说明计算key, value相关性是比较困难的,如果用更精巧的计算方式来代替点乘,可能可以提升性能。

实验C, D表明,大模型是更优的,且dropout是必要的。

如正文所写,实验E探究了可学习的位置编码。可学习的位置编码的效果和三角函数几乎一致。

总结

为了改进RNN不可并行的问题,这篇工作提出了Transformer这一仅由注意力机制构成的模型。Transformer的效果非常出色,不仅训练速度快了,还在两项翻译任务上胜过其他模型。

作者也很期待Transformer在其他任务上的应用。对于序列长度比较大的任务,如图像、音频、视频,可能要使用文中提到的只关注局部的注意力机制。由于序列输出时仍然避免不了串行,作者也在探究如何减少序列输出的串行度。

现在来看,Transformer是近年来最有影响力的深度学习模型之一。它先是在NLP中发扬光大,再逐渐扩散到了CV等领域。文中的一些预测也成为了现实,现在很多论文都在讨论如何在图像中使用注意力,以及如何使用带限制的注意力以降低长序列导致的计算性能问题。

我认为,对于深度学习的初学者,不管是研究什么领域,都应该仔细学习Transformer。在学Transformer之前,最好先了解一下RNN和经典的encoder-decoder架构,再学习注意力模型。有了这些基础,读Transformer论文就会顺利很多。读论文时,最重要的是看懂注意力公式的原理,再看懂自注意力和多头注意力,最后看一看位置编码。其他一些和机器翻译任务相关的设计可以不用那么关注。

总算,我们把《深度学习专项》的最后一门课《序列模型》学完了。这门课主要讨论了和序列数据相关的深度学习方法,介绍了以RNN为代表的经典序列模型和Transformer自注意力模型这两大类模型。同时,这门课还介绍了一些常见自然语言处理问题的基础知识和建模方法。让我们回顾一下RNN、Transformer、NLP这三个方面的知识。

所有知识按技能树的形式组织,学完了上一层的才能学下一层的。部分知识是派生出来的,学会与否不影响后续知识的学习,这些知识的前置条件会特别标出。

RNN

0. RNN 处理的数据种类:序列数据

常见的序列数据有:语音、文字、DNA、视频。

1.1 RNN 的结构

使用这种结构的原因:如果用全连接网络,既无法处理任意长度的句子,又不能共享每一处数据的知识。而 RNN 既能用同样的方法处理每一处数据,又能用一个隐变量表示上下文信息以描述序列关系。

1.2 RNN 的梯度问题

RNN 的梯度可能爆炸也可能消失。梯度爆炸可以通过设置一个梯度最大值来解决。而梯度消失要靠结构改进解决。

下面是 PyTorch 的梯度裁剪函数的使用方法:

1
2
3
4
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

1.3 多层 RNN

竖着堆叠 RNN。由于 RNN 计算较慢,一般叠 3 层就算很多了。

想要进一步提高网络的拟合能力,可以在输出模块里多加一些非时序的神经网络。

GRU (需学习 RNN)

序列一长,靠前的数据的梯度就容易消失。GRU 解决了这一问题。

GRU 改进了普通的 RNN,每次更新隐变量时会使用一个0~1之间的更新比例,防止之前的信息被过快地忘记。

LSTM (需学习 RNN, GRU)

LSTM 的作用和 GRU 类似,效果更好,但更费时。

双向 RNN (需学习 RNN)

等价于正着跑一遍 RNN,倒着跑一遍 RNN,输出结果由前向和后向的两个隐变量决定。

碰到一个新的序列任务时,双向 LSTM 依然是首选。

2. 序列问题的 RNN 建模方法

RNN 只提供了N对N的序列问题的解决方法。对于其他输入输出格式的问题,需要进行巧妙的建模,才能使用 RNN 来解决问题。

其中,最重要的是不等长多对多数据的建模方法。前半段只输入不输出的部分叫做编码器,后半段只输出不输入的部分叫做解码器。这种“编码器-解码器”架构是一种经典的 RNN 架构。

RNN 可以轻松地完成序列生成任务:输出下一个单词的 softmax 的预测结果,挑选概率较高的一个作为下一次的输入,循环执行。找到总体较好的一个输出的算法叫做 Beam Search。

3. 注意力模型

一种加强版 RNN 架构。它依然是一种“编码器-解码器”架构。编码器是双向 RNN,解码器是单向 RNN。每个输出的 RNN 对每个输入都有一个权重,该权重叫做注意力。每个输出从每个输入接收的数据是一个加权和。

Transformer

Transformer 是一类重要的模型。学习此模型之前最好学完 RNN。

Transformer 从多个角度改进了原序列模型。学习 Transformer 时,主要关注以下几方面的知识:

  1. 注意力和多头注意力
  2. 输出是怎样并行计算的
  3. 位置编码

1. 注意力

注意力的本质是全局信息查询。给出问题 Q,去现有的数据库 (K, V) 里查出一个 V 的加权和,权重由 Q, K 的相似度决定。

2.1 自注意力

自注意力是注意力的一个应用。当 Q, K, V 都是同一个张量时,注意力计算起到是特征提取的作用。相对于 CNN,自注意力更能提取全局信息。

2.2 输出对输入的注意力

注意力机制在早期的注意力模型中是用来计算每个输出对每个输入的权重的。Q 来自输出,K, V 来自输入。

2.3 多头注意力

多头注意力是普通注意力计算的一个加强版。首先,它引入了可学习参数,能够让注意力模块学习要提取哪些特征;其次,它可以像卷积层一样提取多个特征。

3. 并行解码

对于输入序列$(x_1, …, x_s)$,它会被编码器编码成中间表示 $\mathbf{z} = (z_1, …, z_s)$。给定$\mathbf{z}$的前提下,解码器输入$(y_1, …, y_t)$,输出$(y_2, …, y_{t+1})$的预测。

为了模拟解码器的串行执行,其第一个多头注意力有掩码机制,盖住了此时刻之后的数据。

4. 位置编码

由于 Transformer 没有利用到数据的先后顺序这一信息,包含位置信息的位置编码会作为额外输入加到输入的张量上。

NLP 知识

为了更好地介绍 RNN,《深度学习专项》补充了很多 NLP知识。

词汇表

为了表示单词,要实现准备一个包含所有要用到的单词的词汇表。每个单词用一个 one-hot 向量表示。若一个单词在词汇表里的序号是 i,则其 one-hot 向量的第 i 个元素为1。

词嵌入 (需学习 RNN 结构、RNN 建模方法)

one-hot 编码是没有任何含义的。词嵌入是每个单词的一种有意义的表示,向量间的差能够表示语义上的差距。

词嵌入要在某些任务中学习得到。词嵌入的学习方法有:语言模型、Word2Vec、负采样、GloVe。

学习好了词嵌入后,词嵌入应该放在其他任务的神经网络的第一层,作为输入数据的一种预处理方法。

BLEU Score

BLEU Score 是为了评价机器翻译而提出的指标。如果模型输出的翻译和参考译文有越多相同的单词、连续2个相同单词、连续3个相同单词……,则得分越高。

总结

《深度学习专项》的第五门课主要介绍了这三个方面的知识。如果你以后不从事 NLP 的研究,可以略过 NLP 的知识。而 RNN 和 Transformer 的知识是必须要学的。学 RNN 时,主要关注各个问题是怎么用 RNN 建模的;而学 Transformer 时,要更加关注模型的结构和原理。

深度学习的技术日新月异。上完了课后,最好去深入阅读一下经典论文,再去跟进最新的论文,尤其是 Transformer 这种较新的领域。

在看这篇总结时,如果你看到了不懂的地方,欢迎回顾我之前的笔记。

《卷积神经网络》总结

前几周,我们学完了《深度学习专项》第四门课《卷积神经网络》。这门课的知识点太多了,想详细地整理出来并不容易。我打算较为宏观地概括一下这门课的主要内容。

《卷积神经网络》先从细节入手,介绍了卷积神经网络(CNN)的基础构件与运算方式。之后,这门课展示了一些图像分类中的经典网络,通过示例介绍了构筑CNN的一般方法。最后,这门课拓展介绍了CNN在目标检测、图像分割、人类识别、风格迁移等具体任务中的应用。在学习时,我们应该着重关注前两部分的内容,应用部分以了解为主。

卷积层

  • 相比全连接层的优势:参数量变少、平移不变性
  • 卷积的运算方法(灰度图、多通道图)
  • 卷积的参数(填充、步幅)
  • 卷积层的运算方法(如何加上bias并送入激活函数)

CNN

  • 三大构件:卷积层、池化层、全连接层
  • 构建方式:卷积接池化,最后全连接
  • 图像尺寸变小,通道数变大

经典网络(LeNet, AlexNet, VGG)

  • 卷积接池化,最后全连接;图像尺寸变小,通道数变大
  • 平均池化进化成最大池化
  • tanh进化到ReLU
  • 逐渐使用更简单的3x3卷积,2x2池化

ResNet

  • 待解决的问题:网络深度增加而性能退化
  • 解决问题的方法:学习一个靠近全等映射的残差
  • 2层残差块的结构、计算公式
  • 常用的ResNet的结构(ResNet-18, ResNet-50)

一般的网络只要学习其有关知识就行了。而ResNet不同,它已经被使用得太广泛了,必须要认真学习一下它的结构。

Inception

  • 1x1卷积的用法
  • 如果利用1x1卷积降低运算量(bottleneck)
  • Inception模块

MobileNet

  • 逐深度可分卷积
  • MobileNetv2降低运算量的bottleneck结构

CNN实现使用技巧

  • 使用开源代码
  • 利用迁移学习(获取预训练模型,在数据集上微调)
  • 数据增强
  • 打比赛时,用多个模型的平均输出提高精度

目标检测

  • 任务定义:从目标定位到目标检测
  • 从滑动窗口算法到基于卷积的滑动窗口算法
  • YOLO
  • NMS
  • 区域提案(region proposal)

语义分割

  • 任务定义
  • 反卷积
  • U-Net架构

人脸识别

  • 任务定义:人脸验证与人脸识别
  • 单样本学习
  • 孪生网络
  • 三元组误差

神经网络风格迁移

  • CNN的“可视化”
  • 任务定义
  • 内容误差与风格误差

入门基于深度学习的计算机视觉相关研究,一定要把图像分类的经典网络学扎实。这些网络是其他任务的基石,英文用backbone来称呼它们。学会了通用的知识后,再去学习某任务特有的知识。

《序列模型》预览

深度学习的第五门课——也是最后一门课——的标题是《序列模型》。更确切地来说,这门课主要围绕人工智能中另一大领域——自然语言处理(NLP),介绍如何用RNN, Transformer等模型处理以序列形式输入输出的数据。

这门课的主要内容有:

RNN

  • 序列任务的输入输出形式
  • RNN的计算方式
  • GRU
  • LSTM
  • 双向RNN
  • 深层RNN

词嵌入

  • 问题定义
  • Word2Vec
  • 负采样
  • GloVe

RNN的应用

  • 语言模型
  • 机器翻译
  • Beam Search
  • Bleu Score

注意力

  • 注意力模型
  • 自注意力
  • Transformer

不同于CNN,序列模型能够用另一种方式对问题建模,尤其擅长于处理和序列数据相关的问题。如果你的研究方向是CV,也不要错过这一门课的学习。很多序列模型都在CV中得到了应用。比如光流模型RAFT就使用到了GRU,很多主流CV backbone都使用到了transformer。

未来的几周我也会继续分享这方面的笔记。不过,我的研究方向不是NLP,代码项目会稍微潦草一些。我打算只用PyTorch复现课堂上讲过的一些简单项目。

大家或许在课本或故事里接触过“熵”这个概念:“熵表示混乱程度。熵只会越变越多,熵增会让宇宙最终走向灭亡”。“熵”也常常出现在机器学习的概念中,比如分类任务会使用到一种叫做“交叉熵”的公式。

那么,“熵”究竟是什么呢?熵的数学单位和意义并没有生活中的其他事物那么直观。我们可以说一元一元的钱、一吨一吨的铁、一升一升的水,却很难说出一单位的熵表示什么。在这篇文章里,我会从非常易懂的例子中引入这个概念,并介绍和熵相关的交叉熵相对熵(KL散度) 等概念,使大家一次性理解熵的原理。

熵的提出——尽可能节省电报费

假设现在是一个世纪以前,人们用电报来传输信息。为了刊登天气预报,报社每天会从气象中心接收电报,获知第二天的天气。这样的电报应该怎么写呢?

我们先假设明天的天气只有“好”和“坏”两种情况。好天气,就是晴天或多云;坏天气,就是刮风或下雨。这样,电报就可以写成“明天天气很好”或者“明天天气不好”。

这种电报的描述确实十分清楚。但是,电报是要收费的——电报里的字越多,花费的钱越多。为了节约成本,我们可以只在电报里写一个字。天气好,就发一个“好”;天气差,就发一个“坏”。由于报社和气象中心已经商量好了,这一条电报线只用来发送明天的天气这一种信息,所以“好”和“坏”只是指代明天的天气,不会有任何歧义。通过这种方式,发电报的成本大大降低了。

但是,这种方式还不是最省钱的。通讯其实使用的是二进制编码。比如如果用两个二进制位,可以表示00, 01, 10, 11这$2^2$种不同的信息。而汉字有上万个,为了区分每一个汉字,一般会使用16个二进制位,以表示$2^{16}=65536$个不同的汉字。要传输一个汉字,就要传输16个0或1的数字。而要表示明天的天气,最节省的情况下,只要一个二进制位就好了——0表示天气不好,1表示天气很好。

这样,我们得出了结论:最节省的情况下,用1位二进制数表示明天的天气就行了。1位二进制数有单位,叫做比特(bit)。因此,我们还可以说:明天天气的熵是1比特。没错,的意思就是最少花几比特的二进制数去传递一种信息。发明出“熵”的人,很可能正在思考怎么用更短的编码去节省一点电报费。

熵与平均编码长度

明天的天气只有两种可能:好、坏。这一情况太简单了,以至于我们可以脱口而出:这个问题的熵是1。

可当问题更复杂、更贴近实际时,熵的计算就没那么简单了。明天的天气,可能有4种情况:

这个问题的熵是多少呢?

刚刚我们也看到了,1个二进制数表示2种情况,2个二进制数表示4种情况。现在有4种情况,只要两位编码就行了:

因此,这个问题的熵是2。

但有人可能对这种编码方式感到不满:“这种编码太乱了,每个二进制位都没有意义。我有一种更好的编码方式。”

确实如此。要表示四种情况,不是非得用2位的编码,而可以用1-3位的编码分别表示不同的信息。可是,这两种方案哪个更节省一些呢?我们不能轻易做出判断。假如大部分时候都是晴天,那么用第二种方案的话很多时候只需要1位编码就行了;而假如大部分时候都是下雨或下雪,则用第一种方案会更好一些。为了判断哪种方案更好,我们还缺了一个重要的信息——每种天气的出现概率。

假如我们知道了每种天气的出现概率,就可以算出某种编码的平均编码长度,进而选择一种更优的编码。让我们看一个例子。假设四种天气的出现概率是80%/10%/5%/5%,则两种编码的平均长度为:

用每种天气的出现概率,乘上每种天气的编码的长度,求和,就可以算出该编码下表示一种天气的平均长度。从结果来看,第二种编码更好一些。事实上,第二种编码是这种概率分布下最短的编码。

只有四种天气,我们还可以通过尝试与猜测,找出最好的编码。可是,如果天气再多一点,编码方式就更复杂了。想找出最好的编码就很困难了。有没有一种能够快速知道最优平均编码长度的方式呢?

让我们从简单的例子出发,一点一点找出规律。假如四种天气的出现概率都是25%,那问题就很简单了,直接令每种天气的编码都是2位就行了。由于每种天气的出现概率都相等,让哪种天气的编码长一点短一点都是不合理的。类似地,假如有八种天气,每种天气的概率都是12.5%,那么应该让每种天气都用3位的编码。

这里的“2位”、“3位”是怎么算出来的呢?很简单,对总的天气数取对数即可。有4种天气,就是$log_24=2$;有8种天气,就是$log_28=3$。

现在,让问题再稍微复杂一点。假如还是晴天、刮风、下雨、下雪这四种天气,每种天气的概率都是25%。现在,报社觉得区分下雨和下雪太麻烦了,想把下雨或下雪当成一种出现概率为50%的天气来看待。这种情况下的最优编码是什么呢?

原来下雨的编码是10,下雪的编码是11。既然它们合并了,把编码也合并一下就行。10和11,共同点是第一位都是1。因此,合并后的编码就是1。这样,我们可以得到新问题下的编码方案。刚刚的四种天气均分的编码方案是最优的,那么这种把两种编码合并后的方案也是最优的。

晴天和刮风的编码长度是2。这个很好理解。25%是4种天气平均分的概率,因此编码长度是$log_24=2$。下雨/下雪的编码长度是1,这个又是怎么得到的呢?50%,可以认为是2种天气平均分的概率,这时的编码长度是$log_22=1$。

由于一般我们只知道每种天气的概率,而不太好算出每种天气被平均分成了几份。因此,我们可以把上面两个对数运算用概率来表达。

更一般地,假设某种天气的出现概率是$P$。我们可以根据规律猜测,当这种天气的编码长度是$-log_2P$时,整个编码方案是最优的。事实上,这个猜测是正确的。

我们来算一下最优编码方案下的平均编码长度,看看每次接收天气预报的电报平均下来要花几个比特。假设第$i$种天气的出现概率是$P(i)$,那么最优平均编码长度就是:

这就是我们经常见到的熵的计算公式了。没错,上一节我们对于熵的定义并不是那么准确。熵其实表示的是理想情况下,某个信息系统的最短平均编码长度

在这个公式里,由于被累加的情况是离散的,我们使用了求和符号。对于连续的情况,要把求和变成求积分。

这个定义有几个要注意的地方。首先,熵是针对某个信息系统的平均情况而言的。这说明,用熵来描述的信息必须要能够分成许多种,就像很多种天气一样。同时,每种具体的信息都得有一个出现概率。我们不是希望让某一次传输信息的编码长度最短,而是希望在大量实验的情况下,令平均编码长度最短。

另外,为什么要说“理想情况下”呢?试想一下,假如有6种天气,每种天气的出现概率相等。这样,每种天气都应该用$log_26$位编码来表示。可是,$log_26$并不是一个整数。实际情况中,我们只能用2~3位编码来表示6种天气。由于编码长度必须是整数,这种最优的编码长度只是说理论上成立,可能实际上并不能实现。

求解最优整数编码的算法叫做“哈夫曼编码”,这是一个经典的算法题。感兴趣的话可以查阅有关资料。

推而广之,熵除了指编码长度这种比较具体的事物以外,还可以表示一些其他的量。这个时候,“编码长度”就不一定是一个整数了,算出来的熵也就更加有实际意义。因此,与其用“无序度”、“信息量”这些笼统的词汇来概括熵,我们可以用一些更具体的话来描述熵:

对于一个信息系统,如果它的熵很低,就说明奇奇怪怪的信息较少,用少量词汇就可以概括;如果它的熵很高,就说它包括了各式各样的信息,需要用更精确的词汇来表达。

最后,再对熵的公式做一个补充。一般情况下,算熵时,对数的底不是2,而是自然常数e。由换底公式

可知,对数用不同的底只是差了一个常数系数而已,使用什么数为底并不影响其相对大小。在使用熵时,我们也只关心多个熵的相对大小,而不怎么会关注绝对数值。

以2为底时,熵的单位是比特。以e为底时,熵的单位是奈特(nat)。

用交叉熵算其他方案的平均编码长度

理解了熵的概念后,像交叉熵这种衍生概念就非常好理解了。在学习所有和熵有关的概念时,都可以把问题转换成“怎么用最节省的编码方式来描述天气”。

假设电报员正在和气象中心商量天气的编码方式。可是,这个电报员刚来这个城市不久,不知道这里是晴天比较多,还是下雨比较多。于是,对于晴天、刮风、下雨、下雪这四种天气,他采用了最朴素的平均法,让每种天气的编码都占2位。

大概100天后,电报员统计出了每种天气的出现频率。他猛然发现,这个城市大部分时间都在下雨。如果让下雨的编码只占1位,会节省得多。于是,他连忙修改了编码的方式。

这时,他开始后悔了:“要是早点用新的编码就好了。两种方案的平均编码长度差了多少呢?”假设各种天气的出现概率如下,我们可以计算出新旧方案的平均编码长度。

就像计算熵一样,我们来用公式表示一下这个不那么好的平均编码长度。之前,电报员为什么会都用2位来表示每一种天气?这是因为,他估计每种天气的出现概率都是25%。也就是说,在算某一个实际概率分布为$P$的信息系统时,我们或许用了概率分布$Q$去估计$P$。在刚刚那个问题里,$Q$就表示所有天气的出现概率相等,$P$就是雨天较多的那个实际概率分布。这样,较差的平均编码长度计算如下:

这是不是和熵的公式

很像?没错,这就是交叉熵的公式。交叉熵表示当你不知道某个系统的概率分布时,用一个估计的概率分布去编码得到的平均编码长度。$-log_2Q(i)$表示的是每一条信息的编码长度,由于编码长度是由我们自己决定的,只能用估计的分布来算,所以它里面用的是$Q$这个分布。而算期望时,得用真实的概率分布$P$。因此,这个式子外面乘上的是$P(i)$。

交叉熵有一个很重要的性质:交叉熵一定不小于熵。熵是最优的编码长度,你估计出来的编码方案,一定不会比最优的更好。所有交叉熵的应用基本上都是利用了这一性质。

在机器学习中,我们会为分类任务使用交叉熵作为损失函数。这正是利用了交叉熵不比熵更大这一性质。让我们以猫狗分类为例看看这是怎么回事。

在猫狗分类中,我们会给每张图片标一个one-hot编码。如果图片里是猫,编码就是[1, 0];如果是狗,编码就是[0, 1]。one-hot编码,其实就是一个概率分布,只不过某种事件的出现概率是100%而已。可以轻松地算出,one-hot编码的熵是0。

机器学习模型做分类任务,其实就是在估计的one-hot编码表示的概率分布。模型输出的概率分布就是交叉熵公式里的$Q$,实际的概率分布,也就是one-hot编码,就是交叉熵公式里的$P$。由于熵是0,交叉熵的值一定大于等于0。因此,交叉熵的值可以表示它和熵——最优的概率分布的信息量——之间的差距。

用KL散度算方案亏了多少

用交叉熵算出旧方案的平均编码长度后,电报员打算统计一下旧编码浪费了多少编码量。既然熵是最优编码的编码长度,那么交叉熵减去熵就能反映出旧编码平均浪费了多少编码长度。这个计算过程如下:

可以总结出,如果我们还是用分布$Q$去估计分布的$P$的话,则这其中损失的编码量可以用下面的公式直接计算。

这个公式描述的量叫做相对熵,又叫做KL散度。KL散度的定义非常易懂,它只不过是交叉熵和熵的差而已,反映了一个分布与另一个分布的差异程度。最理想情况下,$P=Q$,则KL散度为0。

当然,KL散度不是一个距离指标。从公式中能够看出,$D_{KL}(P||Q)\neq D_{KL}(Q||P)$,这个指标并不满足交换律。

KL散度常用来描述正态分布之间的差异。比如VAE(变分自编码器)中,就用了KL散度来比较估计的分布和标准正态分布。计算正态分布间的KL散度时,我们不用从头推导,可以直接去套一个公式。

设一维正态分布$P, Q$的公式如下:

则KL散度为:

在看计算机方向的论文时,如果碰到了KL散度,我们不需要深究其背后的数学公式,只需要理解KL散度的原理,知道它是用来做什么的即可。

总结

在各个学科中,都能见到“熵”的身影。其实,大部分和熵有关的量都是在最基本的熵公式之上的拓展。只要理解了熵最核心的意义,理解其他和熵相关的量会非常轻松。希望大家能够通过这篇文章里的例子,直观地理解熵的意义。

参考资料

我的这篇文章基本照抄了三篇介绍熵的文章。这三篇文章写得非常易懂,强烈推荐阅读。

Entropy Demystified

Cross-Entropy Demystified

KL Divergence Demystified

有关KL散度的推导可以参见这篇博文:

KL散度(Kullback-Leibler Divergence)介绍及详细公式推导