动手学深度学习

978-7-115-49084-1
作者: 阿斯顿·张(Aston Zhang) 李沐(Mu Li) [美] 扎卡里·C. 立顿(Zachary C. Lipton) [德] 亚历山大·J. 斯莫拉(Alexander J. Smola)
译者:
编辑: 杨海玲

图书目录:

详情

本书不仅阐述深度学习的算法原理,还演示它们的实现和运行。本书共分3个部分:第一部分介绍深度学习的背景,提供预备知识,并包括深度学习最基础的概念和技术;第二部分描述深度学习计算的重要组成部分,还解释近年来令深度学习在多个领域大获成功的卷积神经网络和循环神经网络;第三部分评价优化算法,检验影响深度学习计算性能的重要因素,并分别列举深度学习在计算机视觉和自然语言处理中的重要应用。

图书摘要

版权信息

书名:动手学深度学习

ISBN:978-7-115-49084-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。



著    阿斯顿·张(Aston Zhang)

     李沐(Mu Li)

     [美]扎卡里·C.立顿(Zachary C. Lipton)

     [德]亚历山大· J.斯莫拉(Alexander J. Smola)

责任编辑 杨海玲

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e49084”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


本书旨在为读者提供有关深度学习的交互式学习体验。书中不仅阐述深度学习的算法原理,还演示它们的实现和运行。与传统图书不同,本书的每一节都是一个可以下载并运行的 Jupyter记事本,它将文字、公式、图像、代码和运行结果结合在了一起。此外,读者还可以参与书中内容的讨论。

全书的内容分为3个部分:第一部分介绍深度学习的背景,提供预备知识,并包括深度学习最基础的概念和技术;第二部分描述深度学习计算的重要组成部分,还解释近年来令深度学习在多个领域大获成功的卷积神经网络和循环神经网络;第三部分评价优化算法,检验影响深度学习计算性能的重要因素,并分别列举深度学习在计算机视觉和自然语言处理中的重要应用。

本书同时覆盖深度学习的方法和实践,主要面向在校大学生、技术人员和研究人员。阅读本书需要读者了解基本的Python编程或附录中描述的线性代数、微分和概率基础。


这是一本及时且引人入胜的书。它不仅提供了深度学习原理的全面概述,还提供了具有编程代码的详细算法,此外,还提供了计算机视觉和自然语言处理中有关深度学习的最新介绍。如果你想钻研深度学习,请研读这本书!

韩家炜

ACM 院士、IEEE 院士

美国伊利诺伊大学香槟分校计算机系 Abel Bliss 教授

这是对机器学习文献的一个很受欢迎的补充,重点是通过集成Jupyter记事本实现的动手经验。深度学习的学生应该能体会到,这对于熟练掌握这一领域是非常宝贵的。

Bernhard Schölkopf

ACM 院士、德国国家科学院院士

德国马克斯•普朗克研究所智能系统院院长

这本书基于MXNet框架来介绍深度学习技术,书中代码可谓“所学即所用”,为喜欢通过Python代码进行学习的读者接触、了解深度学习技术提供了很大的便利。

周志华

ACM 院士、IEEE 院士、AAAS 院士

南京大学计算机科学与技术系主任

虽然业界已经有不错的深度学习方面的书籍,但都不够紧密结合工业界的应用实践。 我认为《动手学深度学习》是最适合工业界研发工程师学习的,因为这本书把算法理论、应用场景、代码实例都完美地联系在一起,引导读者把理论学习和应用实践紧密结合,知行合一,在动手中学习,在体会和领会中不断深化对深度学习的理解。 因此我毫无保留地向广大的读者强烈推荐《动手学深度学习》。

余凯

地平线公司创始人、首席执行官

强烈推荐这本书!它其实远不只是一本书:它不仅讲解深度学习背后的数学原理,更是一个编程工作台与记事本,让读者可以一边动手学习一边收到反馈,它还是个开源社区平台,让大家可以交流。作为在AI学术界和工业界都长期工作过的人,我特别赞赏这种手脑一体的学习方式,既能增强实践能力,又可以在解决问题中锻炼独立思考和批判性思维。

作者们是算法、工程兼强的业界翘楚,他们能奉献出这样的一本好的开源书,为他们点赞!

漆远

蚂蚁金服副总裁、首席人工智能科学家

这是一本基于Apache MXNet的深度学习实战书籍,可以帮助读者快速上手并掌握使用深度学习工具的基本技能。本书的几个作者都在机器学习领域有着非常丰富的经验。他们不光有大量的工业界实践经验,也有非常高的学术成就,所以对机器学习领域的前沿算法理解深刻。这使得作者们在提供优质代码的同时,也可以把最前沿的算法和概念深入浅出地介绍给读者。这本书可以帮助深度学习实践者快速提升自己的能力。

张潼

腾讯人工智能实验室主任

一年前作者开始在将门技术社群中做深度学习的系列讲座,当时我就对动手式讲座的内容和形式感到耳目一新。一年过去,看到《动手学深度学习》在持续精心打磨后终于成书出版,感觉十分欣喜!

深度学习是当前人工智能研究中的热门领域,吸引了大量感兴趣的开发者踊跃学习相关的开发技术。然而对大多数学习者而言,掌握深度学习是一件很不容易的事情,需要相继翻越数学基础、算法理论、编程开发、领域应用、软硬优化等几座大山。因此学习过程不容易一帆风顺,我也看到很多学习者还没进入开发环节就在理论学习的过程中抱憾放弃了。然而《动手学深度学习》却是一本很容易让学习者上瘾的书,它最大的特色是强调在动手编程中学习理论和培养实战能力。阅读本书最愉悦的感受是它很好地平衡了理论介绍和编程实操,内容简明扼要,衔接自然流畅,既反映了现代深度学习的进展,又兼具易学和实用特性,是深度学习爱好者难得的学习材料。特别值得称赞的是本书选择了Jupyter记事本作为开发学习环境,将教材、文档和代码统一起来,给读者提供了可以立即尝试修改代码和观察运行效果的交互式的学习体验,使学习充满了乐趣。

在过去的一年中,作者和社区成员对《动手学深度学习》进行了大量优化修改才得以成书,可以说这是一本深度学习前沿实践者给深度学习爱好者带来的诚心之作,相信大家都能在阅读和实践中拥有一样的共鸣。

沈强

将门创投创始合伙人

献给我们的家人


就在几年前,不管在大公司还是创业公司,都鲜有工程师和科学家将深度学习应用到智能产品与服务中。作为深度学习前身的神经网络,才刚刚摆脱被机器学习学术界认为是过时工具的印象。那个时候,即使是机器学习也非新闻头条的常客。它仅仅被看作是一门具有前瞻性,并拥有一系列小范围实际应用的学科。在包含计算机视觉和自然语言处理在内的实际应用通常需要大量的相关领域知识:这些实际应用被视为相互独立的领域,而机器学习只占其中一小部分。

然而仅仅在这几年之内,深度学习便令全世界大吃一惊。它非常有力地推动了计算机视觉、自然语言处理、自动语音识别、强化学习和统计建模等多个领域的快速发展。随着这些领域的不断进步,我们现在可以制造自动驾驶的汽车,基于短信、邮件甚至电话的自动回复系统,以及在围棋中击败最优秀人类选手的软件。这些由深度学习带来的新工具也正产生着广泛的影响:它们改变了电影制作和疾病诊断的方式,并在从天体物理学到生物学等各个基础科学中扮演越来越重要的角色。

与此同时,深度学习也给它的使用者们带来了独一无二的挑战:任何单一的应用都汇集了各学科的知识。具体来说,应用深度学习需要同时理解:

同样,我们几位作者也面临前所未有的挑战:我们需要在有限的篇幅里糅合深度学习的多方面知识,从而使读者能够较快理解并应用深度学习技术。本书代表了我们的一种尝试:我们将教给读者概念、背景知识和代码;我们将在同一个地方阐述剖析问题所需的批判性思维、解决问题所需的数学知识,以及实现解决方案所需的工程技能。

我们在2017年7月启动了写作这本书的项目。当时我们需要向用户解释Apache MXNet的新接口Gluon。遗憾的是,我们并没有找到任何一个资源可以同时满足以下几点需求:

那时,我们在博客和GitHub上找到了大量的演示特定深度学习框架(例如用TensorFlow进行数值计算)或实现特定模型(例如AlexNet、ResNet等)的示例代码。这些示例代码的一大价值在于提供了教科书或论文往往省略的实现细节,比如数据的处理和运算的高效率实现。如果不了解这些,即使能将算法倒背如流,也难以将算法应用到自己的项目中去。此外,这些示例代码还使得用户能通过观察修改代码所导致的结果变化而快速验证想法、积累经验。因此,我们坚信动手实践对于学习深度学习的重要性。然而可惜的是,这些示例代码通常侧重于如何实现给定的方法,却忽略了有关算法设计的探究或者实现细节的解释。虽然在像Distill这样的网站和某些博客上出现了一些有关算法设计和实现细节的讨论,但它们常常缺少示例代码,并通常仅覆盖深度学习的一小部分。

另外,我们欣喜地看到了一些有关深度学习的教科书不断问世,其中最著名的要数Goodfellow、Bengio和Courville的《深度学习》。该书梳理了深度学习背后的众多概念与方法,是一本极为优秀的教材。然而,这类资源并没有将概念描述与实际代码相结合,以至于有时会令读者对如何实现它们感到毫无头绪。除了这些以外,商业课程提供者们虽然制作了众多的优质资源,但它们的付费门槛令不少用户望而生畏。

正因为这样,深度学习用户,尤其是初学者,往往不得不参考来源不同的多种资料。例如,通过教科书或者论文来掌握算法及相关数学知识,阅读线上文档学习深度学习框架的使用方法,然后寻找感兴趣的算法在这个框架上的实现,并摸索如何将它应用到自己的项目中去。如果你正亲身经历这一过程,你可能会感到痛苦:不同来源的资料有时难以相互一一对应,即便能够对应也可能需要花费大量的精力。例如,我们需要将某篇论文公式中的数学变量与某段网上实现中的程序变量一一对应,并在代码中找到论文可能没交代清楚的实现细节,甚至要为运行不同的代码安装不同的运行环境。

针对以上存在的痛点,我们正在着手创建一个为实现以下目标的统一资源:

这些目标往往互有冲突:公式、定理和引用最容易通过LaTeX进行管理和展示,代码自然应该用简单易懂的Python描述,而网页本身应该是一堆HTML及配套的CSS和JavaScript。此外,我们希望这个资源可以作为可执行代码、实体书以及网站。然而,目前并没有任何工具可以完美地满足以上所有需求。

因此,我们不得不自己来集成这样的一个工作流。我们决定在GitHub上分享源代码并允许提交编辑,通过Jupyter记事本来整合代码、公式、文本、图片等,使用Sphinx作为渲染引擎来生成不同格式的输出,并使用Discourse作为论坛。虽然我们的系统尚未完善,但这些选择在互有冲突的目标之间取得了较好的折中。这很可能是使用这种集成工作流发布的第一本书。

本书的两位中国作者曾每周末在线免费讲授“动手学深度学习”系列课程。课程的讲义自然成为了本书内容的蓝本。这个课程持续了5个月,其间近3 000名同学参与了讨论,并贡献了5 000多个有价值的讨论,特别是其中几个参加比赛的练习很受欢迎。这个课程的受欢迎程度出乎我们的意料。尽管我们将课件和课程视频都公开在了网上,但我们同时觉得出版成纸质书也许能让更多喜爱阅读的读者受益。因此,我们委托人民邮电出版社来出版这本书。

从蓝本到成书花费了更多的时间。我们对涉及的所有技术点补充了背景介绍,并使用了更加严谨的写作风格,还对版式和示意图做了大量修改。书中所有的代码执行结果都是自动生成的,任何改动都会触发对书中每一段代码的测试,以保证读者在动手实践时能复现结果。

我们的初衷是让更多人更容易地使用深度学习。为了让大家能够便利地获取这些资源,我们保留了免费的网站内容,并且通过不收取稿费的方式来降低纸质书的价格,使更多人有能力购买。

我们无比感谢本书的中英文版稿件贡献者和论坛用户。他们帮助增添或改进了书中内容并提供了有价值的反馈。特别地,我们要感谢每一位为这本中文版开源书提交内容改动的贡献者。这些贡献者的GitHub用户名或姓名是(排名不分先后):许致中、邓杨、崔永明、Aaron Sun、陈斌斌、曾元豪、周长安、李昂、王晨光、Chaitanya Prakash Bapat、金杰、赵小华、戴作卓、刘捷、张建浩、梓善、唐佐林、DHRUV536、丁海、郭晶博、段弘、杨英明、林海滨、范舟、李律、李阳、夏鲁豫、张鹏、徐曦、Kangel Zenn、Richard CUI、郭云鹏、hank123456、金颢、hardfish82、何通、高剑伟、王海龙、htoooth、hufuyu、Kun Hu、刘俊朋、沈海晨、韩承宇、张钟越、罗晶、jiqirer、贾忠祥、姜蔚蔚、田宇琛、王曜、李凯、兰青、王乐园、Leonard Lausen、张雷、鄭宇翔、linbojin、lingss0918、杨大卫、刘佳、戴玮、贾老坏、陆明、张亚鹏、李超、周俊佐、Liang Jinzheng、童话、彭小平、王皓、彭大发、彭远卓、黄瓒、解浚源、彭艺宇、刘铭、吴俊、刘睿、张绍明、施洪、刘天池、廖翊康、施行健、孙畔勇、查晟、郑帅、任杰骥、王海珍、王鑫、wangzhe258369、王振荟、周军、吴侃、汪磊、wudayo、徐驰、夏根源、何孝霆、谢国超、刘新伟、肖梅峰、黄晓烽、燕文磊、王贻达、马逸飞、邱怡轩、吴勇、杨培文、余峰、Peng Yu、王雨薇、王宇翔、喻心悦、赵越、刘忆智、张航、郑达、陈志、周航、张帜、周远、汪汇泽、谢乘胜、aitehappiness、张满闯、孙焱、林健、董进、陈宇泓、魏耀武、田慧嫒、陈琛、许柏楠、bowcr、张宇楠、王晨、李居正、王宗冰、刘垣德。谢谢你们帮忙改进这本书。

本书的初稿在中国科学技术大学、上海财经大学的“深度学习”课程,以及浙江大学的“物联网与信息处理”课程和上海交通大学的“面向视觉识别的卷积神经网络”课程中被用于教学。我们在此感谢这些课程的师生,特别是连德富教授、王智教授和罗家佳教授,感谢他们对改进本书提供的宝贵意见。

此外,我们感谢Amazon Web Services,特别是Swami Sivasubramanian、Raju Gulabani、Charlie Bell和Andrew Jassy在我们撰写本书时给予的慷慨支持。如果没有可用的时间、资源以及来自同事们的讨论和鼓励,就没有这本书的项目。我们还要感谢Apache MXNet团队实现了很多本书所使用的特性。另外,经过同事们的校勘,本书的质量得到了极大的提升。在此我们一一列出章节和校勘人,以表示我们由衷的感谢:引言的校勘人为金颢,预备知识的校勘人为吴俊,深度学习基础的校勘人为张航、王晨光、林海滨,深度学习计算的校勘人为查晟,卷积神经网络的校勘人为张帜、何通,循环神经网络的校勘人为查晟,优化算法的校勘人为郑帅,计算性能的校勘人为郑达、吴俊,计算机视觉的校勘人为解浚源、张帜、何通、张航,自然语言处理的校勘人为王晨光,附录的校勘人为金颢。

感谢将门创投,特别是王慧、高欣欣、常铭珊和白玉,为本书的两位中国作者讲授“动手学深度学习”系列课程提供了平台。感谢所有参与这一系列课程的数千名同学们。感谢Amazon Web Services中国团队的同事们,特别是费良宏和王晨对作者的支持与鼓励。感谢本书论坛的3位版主:王鑫、夏鲁豫和杨培文。他们牺牲了自己宝贵的休息时间来回复大家的提问。感谢人民邮电出版社的杨海玲编辑为我们在本书的出版过程中提供的各种帮助。

最后,我们要感谢我们的家人。谢谢你们一直陪伴着我们。

本书的英文版Dive into Deep Learning是加州大学伯克利分校2019年春学期“Introduction to Deep Learning”(深度学习导论)课程的教材。截至2019年春学期,本书中的内容已被全球15所知名大学用于教学。本书的学习社区、免费教学资源(课件、教学视频、更多习题等),以及用于本书学习或教学的免费计算资源(仅限学生和老师)的申请方法在本书网站 https://zh.d2l.ai 上发布。诚然,将算法、公式、图片、代码和样例统一进一本适合阅读的书,并以具有交互式体验的Jupyter记事本文件的形式提供给读者,是对我们的极大挑战。书中难免有很多疏忽的地方,敬请原谅,并希望读者能通过每一节后面的二维码向我们反馈阅读本书过程中发现的问题。

结尾处,附上陆游的一句诗作为勉励:

“纸上得来终觉浅,绝知此事要躬行。”

阿斯顿·张、李沐、扎卡里· C. 立顿、亚历山大· J. 斯莫拉

2019年5月


本书将全面介绍深度学习从模型构造到模型训练的方方面面,以及它们在计算机视觉和自然语言处理中的应用。我们不仅将阐述算法原理,还将基于Apache MXNet对算法进行实现,并实际运行它们。本书的每一节都是一个Jupyter记事本。它将文字、公式、图像、代码和运行结果结合在了一起。读者不但能直接阅读它们,而且可以运行它们以获得交互式的学习体验。

本书面向希望了解深度学习,特别是对实际使用深度学习感兴趣的大学生、工程师和研究人员。本书并不要求读者有任何深度学习或者机器学习的背景知识,我们将从头开始解释每一个概念。虽然深度学习技术与应用的阐述涉及了数学和编程,但读者只需了解基础的数学和编程,如基础的线性代数、微分和概率,以及基本的Python编程知识。在附录A中我们提供了本书涉及的主要数学知识供读者参考。如果读者之前没有接触过Python,可以参考其中文教程或英文教程。当然,如果读者只对本书中的数学部分感兴趣,可以忽略掉编程部分,反之亦然。

本书内容大体可以分为3个部分。

图0-1描绘了本书的结构,其中由A章指向B章的箭头表明A章的知识有助于理解B章的内容。

图0-1 本书的结构

本书的一大特点是每一节的代码都是可以运行的。读者可以改动代码后重新运行,并通过运行结果进一步理解改动所带来的影响。我们认为,这种交互式的学习体验对于学习深度学习非常重要。因为深度学习目前并没有很好的理论解释框架,很多论断只可意会。文字解释在这时候可能比较苍白无力,而且不足以覆盖所有细节。读者需要不断改动代码、观察运行结果并总结经验,从而逐步领悟和掌握深度学习。

本书的代码基于Apache MXNet实现。MXNet是一个开源的深度学习框架。它是AWS(亚马逊云计算服务)首选的深度学习框架,也被众多学校和公司使用。为了避免重复描述,我们将本书中多次使用的函数和类封装在d2lzh包中(包的名称源于本书的网站地址)。这些函数和类的定义的所在章节已在附录F里列出。但是,因为深度学习发展极为迅速,未来版本的MXNet可能会造成书中部分代码无法正常运行。遇到相关问题可参考2.1节来更新代码和运行环境。如果读者想了解运行本书代码所依赖的MXNet和d2lzh包的版本号,也可参考2.1节。

我们提供代码的主要目的在于增加一个在文字、图像和公式外的学习深度学习算法的方式,以及一个便于理解各个算法在真实数据上的实际效果的交互式环境。书中只使用了MXNet的ndarrayautogradgluon等模块或包的基础功能,从而使读者尽可能了解深度学习算法的实现细节。即便读者在研究和工作中使用的是其他深度学习框架,书中的代码也有助于读者更好地理解和应用深度学习算法。

本书的网站是https://zh.d2l.ai,上面提供了学习社区地址和GitHub开源地址。如果读者对书中某节内容有疑惑,可扫一扫该节开始的二维码参与该节内容的讨论。值得一提的是,在有关Kaggle比赛章节的讨论区中,众多社区成员提供了丰富的高水平方法,我们强烈推荐给大家。希望诸位积极参与学习社区中的讨论,并相信大家一定会有所收获。本书作者和MXNet开发人员也时常参与社区中的讨论。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供如下资源:

要获得以上配套资源,请在异步社区本书页面中点击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

如果您是教师,希望获得教学配套资源,请在社区本书页面中直接联系本书的责任编辑。

您还可以扫码右侧二维码, 关注【异步社区】微信公众号,回复“e49084”直接获取,同时可以获得异步社区15天VIP会员卡,近千本电子书免费畅读。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


数相关符号

符  号

含  义

x

标量

x

向量

X

矩阵

张量

集合相关符号

符  号

含  义

χ

集合

实数集合

n维的实数向量集合

xy列的实数矩阵集合

操作符相关符号

符  号

含  义

(⋅)⊤

向量或矩阵的转置

按元素相乘,即阿达马(Hadamard)积

|χ|

集合χ中元素个数

Lp范数

L2范数

连加

连乘

函数相关符号

符  号

含  义

f (·)

函数

log(·)

自然对数函数

exp(·)

指数函数

导数和梯度相关符号

符  号

含  义

y关于x的导数

y关于x的偏导数

y关于·的梯度

概率和统计相关符号

符  号

含  义

P(·)

概率分布

·~ P

随机变量·的概率分布是P

P(·|·)

条件概率分布

E.( f (·))

函数f (·)对·的数学期望

复杂度相关符号

符  号

含  义

大O符号(渐进符号)


你可能已经接触过编程,并开发过一两款程序。同时你可能读过关于深度学习或者机器学习的铺天盖地的报道,尽管很多时候它们被赋予了更广义的名字——人工智能。实际上,或者说幸运的是,大部分程序并不需要深度学习或者是更广义上的人工智能技术。例如,如果我们要为一台微波炉编写一个用户界面,只需要一点儿工夫我们便能设计出十几个按钮以及一系列能精确描述微波炉在各种情况下的表现的规则;再比如,假设我们要编写一个电子邮件客户端。这样的程序比微波炉要复杂一些,但我们还是可以沉下心来一步一步思考:客户端的用户界面将需要几个输入框来接受收件人、主题、邮件正文等,程序将监听键盘输入并写入一个缓冲区,然后将它们显示在相应的输入框中。当用户点击“发送”按钮时,我们需要检查收件人邮箱地址的格式是否正确,并检查邮件主题是否为空,或在主题为空时警告用户,而后用相应的协议传送邮件。

扫码直达讨论区

值得注意的是,在以上两个例子中,我们都不需要收集真实世界中的数据,也不需要系统地提取这些数据的特征。只要有充足的时间,我们的常识与编程技巧已经足够让我们完成任务。

与此同时,我们很容易就能找到一些连世界上最好的程序员也无法仅用编程技巧解决的简单问题。例如,假设我们想要编写一个判定一张图像中有没有猫的程序。这件事听起来好像很简单,对不对?程序只需要对每张输入图像输出“真”(表示有猫)或者“假”(表示无猫)即可。但令人惊讶的是,即使是世界上最优秀的计算机科学家和程序员也不懂如何编写这样的程序。

我们该从哪里入手呢?我们先进一步简化这个问题:若假设所有图像的高和宽都是同样的 400 像素大小,一个像素由红绿蓝3个值构成,那么一张图像就由近 50 万个数值表示。那么哪些数值隐藏着我们需要的信息呢?是所有数值的平均数,还是4个角的数值,抑或是图像中的某一个特别的点?事实上,要想解读图像中的内容,需要寻找仅仅在结合成千上万的数值时才会出现的特征,如边缘、质地、形状、眼睛、鼻子等,最终才能判断图像中是否有猫。

一种解决以上问题的思路是逆向思考。与其设计一个解决问题的程序,不如从最终的需求入手来寻找一个解决方案。事实上,这也是目前的机器学习和深度学习应用共同的核心思想:我们可以称其为“用数据编程”。与其枯坐在房间里思考怎么设计一个识别猫的程序,不如利用人类肉眼在图像中识别猫的能力。我们可以收集一些已知包含猫与不包含猫的真实图像,然后我们的目标就转化成如何从这些图像入手得到一个可以推断出图像中是否有猫的函数。这个函数的形式通常通过我们的知识来针对特定问题选定。例如,我们使用一个二次函数来判断图像中是否有猫,但是像二次函数系数值这样的函数参数的具体值则是通过数据来确定。

通俗来说,机器学习是一门讨论各式各样的适用于不同问题的函数形式,以及如何使用数据来有效地获取函数参数具体值的学科。深度学习是指机器学习中的一类函数,它们的形式通常为多层神经网络。近年来,仰仗着大数据集和强大的硬件,深度学习已逐渐成为处理图像、文本语料和声音信号等复杂高维度数据的主要方法。

我们现在正处于一个程序设计得到深度学习的帮助越来越多的时代。这可以说是计算机科学历史上的一个分水岭。举个例子,深度学习已经在你的手机里:拼写校正、语音识别、认出社交媒体照片里的好友们等。得益于优秀的算法、快速而廉价的算力、前所未有的大量数据以及强大的软件工具,如今大多数软件工程师都有能力建立复杂的模型来解决10年前连最优秀的科学家都觉得棘手的问题。

本书希望能帮助读者进入深度学习的浪潮中。我们希望结合数学、代码和样例让深度学习变得触手可及。本书不要求读者具有高深的数学或编程背景,我们将随着章节的发展逐一解释所需要的知识。更值得一提的是,本书的每一节都是一个可以独立运行的 Jupyter 记事本。读者可以从网上获得这些记事本,并且可以在个人电脑或云端服务器上执行它们。这样读者就可以随意改动书中的代码并得到及时反馈。我们希望本书能帮助和启发新一代的程序员、创业者、统计学家、生物学家,以及所有对深度学习感兴趣的人。

虽然深度学习似乎是最近几年刚兴起的名词,但它所基于的神经网络模型和用数据编程的核心思想已经被研究了数百年。自古以来,人类就一直渴望能从数据中分析出预知未来的窍门。实际上,数据分析正是大部分自然科学的本质,我们希望从日常的观测中提取规则,并找寻不确定性。

早在17世纪,雅各比•伯努利(1655—1705)提出了描述只有两种结果的随机过程(如抛掷一枚硬币)的伯努利分布。大约一个世纪之后,卡尔•弗里德里希•高斯(1777—1855)发明了今日仍广泛用在从保险计算到医学诊断等领域的最小二乘法。概率论、统计学和模式识别等工具帮助自然科学的工作者从数据回归到自然定律,从而发现了如欧姆定律(描述电阻两端电压和流经电阻电流关系的定律)这类可以用线性模型完美表达的一系列自然法则。

即使是在中世纪,数学家也热衷于利用统计学来做出估计。例如,在雅各比•科贝尔(1460—1533)的几何书中记载了使用 16 名男子的平均脚长来估计男子的平均脚长。

如图1-1所示,在这个研究中,16位成年男子被要求在离开教堂时站成一排并把脚贴在一起,而后他们脚的总长度除以16得到了一个估计:这个数字大约相当于今日的0.3米。这个算法之后又被改进,以应对特异形状的脚—— 最长和最短的脚不计入,只对剩余的脚长取平均值,即裁剪平均值的雏形。

图1-1 在中世纪,16名男子的平均脚长被用来估计男子的平均脚长

现代统计学在 20 世纪的真正腾飞要归功于数据的收集和发布。统计学巨匠之一罗纳德•费雪(1890—1962)对统计学理论和统计学在基因学中的应用功不可没。他发明的许多算法和公式,例如线性判别分析和费雪信息,仍经常被使用。即使是他在 1936 年发布的 Iris 数据集,仍然偶尔被用于演示机器学习算法。

克劳德•香农(1916—2001)的信息论以及阿兰•图灵(1912—1954)的计算理论也对机器学习有深远影响。图灵在他著名的论文《计算机器与智能》中提出了“机器可以思考吗?”这样一个问题[56]。在他描述的“图灵测试”中,如果一个人在使用文本交互时不能区分他的对话对象到底是人类还是机器的话,那么即可认为这台机器是有智能的。时至今日,智能机器的发展可谓日新月异。

另一个对深度学习有重大影响的领域是神经科学与心理学。既然人类显然能够展现出智能,那么对于解释并逆向工程人类智能机理的探究也在情理之中。最早的算法之一是由唐纳德•赫布(1904—1985)正式提出的。在他开创性的著作《行为的组织》中,他提出神经是通过正向强化来学习的,即赫布理论[21]。赫布理论是感知机学习算法的原型,并成为支撑今日深度学习的随机梯度下降算法的基石:强化合意的行为、惩罚不合意的行为,最终获得优良的神经网络参数。

来源于生物学的灵感是神经网络名字的由来。这类研究者可以追溯到一个多世纪前的亚历山大•贝恩(1818—1903)和查尔斯•斯科特•谢灵顿(1857—1952)。研究者们尝试组建模仿神经元互动的计算电路。随着时间流逝,神经网络的生物学解释被稀释,但仍保留了这个名字。时至今日,绝大多数神经网络都包含以下的核心原则。

在最初的快速发展之后,自约1995年起至2005年,大部分机器学习研究者的视线从神经网络上移开了。这是由于多种原因。首先,训练神经网络需要极强的计算力。尽管20世纪末内存已经足够,计算力却不够充足。其次,当时使用的数据集也相对小得多。费雪在1936年发布的的Iris 数据集仅有150个样本,并被广泛用于测试算法的性能。具有6万个样本的MNIST数据集在当时已经被认为是非常庞大了,尽管它如今已被认为是典型的简单数据集。由于数据和计算力的稀缺,从经验上来说,如核方法、决策树和概率图模型等统计工具更优。它们不像神经网络一样需要长时间的训练,并且在强大的理论保证下提供可以预测的结果。

互联网的崛起、价廉物美的传感器和低价的存储器令我们越来越容易获取大量数据。加之便宜的计算力,尤其是原本为电脑游戏设计的GPU的出现,前面描述的情况改变了许多。一瞬间,原本被认为不可能的算法和模型变得触手可及。这样的发展趋势从表1-1中可见一斑。

表1-1 发展趋势

年代

数据样本个数

内存

每秒浮点计算数

1970

102(Iris)

1 KB

105(Intel 8080)

1980

103(波士顿房价)

100 KB

106(Intel 80186)

1990

104(手写字符识别)

10 MB

107(Intel 80486)

2000

107(网页)

100 MB

109(Intel Core)

2010

1010(广告)

1 GB

1012(NVIDIA C2050)

2020

1012(社交网络)

100 GB

1015(NVIDIA DGX-2)

很显然,存储容量没能跟上数据量增长的步伐。与此同时,计算力的增长又盖过了数据量的增长。这样的趋势使得统计模型可以在优化参数上投入更多的计算力,但同时需要提高存储的利用效率,例如使用非线性处理单元。这也相应导致了机器学习和统计学的最优选择从广义线性模型及核方法变化为深度多层神经网络。这样的变化正是诸如多层感知机、卷积神经网络、长短期记忆循环神经网络和Q学习等深度学习的支柱模型在过去10年从坐了数十年的冷板凳上站起来被“重新发现”的原因。

近年来在统计模型、应用和算法上的进展常被拿来与寒武纪大爆发(历史上物种数量大爆发的一个时期)做比较。但这些进展不仅仅是因为可用资源变多了而让我们得以用新瓶装旧酒。下面仅列出了近10年来深度学习长足发展的部分原因。

系统研究者负责构建更好的工具,统计学家建立更好的模型。这样的分工使工作大大简化。举例来说,在 2014 年时,训练一个逻辑回归模型曾是卡内基梅隆大学布置给机器学习方向的新入学博士生的作业问题。时至今日,这个问题只需要少于 10 行的代码便可以完成,普通的程序员都可以做到。

长期以来机器学习总能完成其他方法难以完成的目标。例如,自20世纪 90 年代起,邮件的分拣就开始使用光学字符识别。实际上这正是知名的 MNIST 和 USPS 手写数字数据集的来源。机器学习也是电子支付系统的支柱,可以用于读取银行支票、进行授信评分以及防止金融欺诈。机器学习算法在网络上被用来提供搜索结果、个性化推荐和网页排序。虽然长期处于公众视野之外,但是机器学习已经渗透到了我们工作和生活的方方面面。直到近年来,在此前认为无法被解决的问题以及直接关系到消费者的问题上取得突破性进展后,机器学习才逐渐变成公众的焦点。下列进展基本归功于深度学习。

以上列出的仅仅是近年来深度学习所取得的成果的冰山一角。机器人学、物流管理、计算生物学、粒子物理学和天文学近年来的发展也有一部分要归功于深度学习。可以看到,深度学习已经逐渐演变成一个工程师和科学家皆可使用的普适工具。

在描述深度学习的特点之前,我们先回顾并概括一下机器学习和深度学习的关系。机器学习研究如何使计算机系统利用经验改善性能。它是人工智能领域的分支,也是实现人工智能的一种手段。在机器学习的众多研究方向中,表征学习关注如何自动找出表示数据的合适方式,以便更好地将输入变换为正确的输出,而本书要重点探讨的深度学习是具有多级表示的表征学习方法。在每一级(从原始数据开始),深度学习通过简单的函数将该级的表示变换为更高级的表示。因此,深度学习模型也可以看作是由许多简单函数复合而成的函数。当这些复合的函数足够多时,深度学习模型就可以表达非常复杂的变换。

深度学习可以逐级表示越来越抽象的概念或模式。以图像为例,它的输入是一堆原始像素值。深度学习模型中,图像可以逐级表示为特定位置和角度的边缘、由边缘组合得出的花纹、由多种花纹进一步汇合得到的特定部位的模式等。最终,模型能够较容易根据更高级的表示完成给定的任务,如识别图像中的物体。值得一提的是,作为表征学习的一种,深度学习将自动找出每一级表示数据的合适方式。

因此,深度学习的一个外在特点是端到端的训练。也就是说,并不是将单独调试的部分拼凑起来组成一个系统,而是将整个系统组建好之后一起训练。比如说,计算机视觉科学家之前曾一度将特征抽取与机器学习模型的构建分开处理,像是 Canny边缘探测[6]和SIFT特征提取[37]曾占据统治性地位达 10 年以上,但这也就是人类能找到的最好方法了。当深度学习进入这个领域后,这些特征提取方法就被性能更强的自动优化的逐级过滤器替代了。

相似地,在自然语言处理领域,词袋模型多年来都被认为是不二之选[46]。词袋模型是将一个句子映射到一个词频向量的模型,但这样的做法完全忽视了单词的排列顺序或者句中的标点符号。不幸的是,我们也没有能力来手工抽取更好的特征。但是自动化的算法反而可以从所有可能的特征中搜寻最好的那个,这也带来了极大的进步。例如,语义相关的词嵌入能够在向量空间中完成如下推理:“柏林-德国 + 中国 = 北京”。可以看出,这些都是端到端训练整个系统带来的效果。

除端到端的训练以外,我们也正在经历从含参数统计模型转向完全无参数的模型。当数据非常稀缺时,我们需要通过简化对现实的假设来得到实用的模型。当数据充足时,我们就可以用能更好地拟合现实的无参数模型来替代这些含参数模型。这也使我们可以得到更精确的模型,尽管需要牺牲一些可解释性。

相对于其他经典的机器学习方法而言,深度学习的不同在于对非最优解的包容、非凸非线性优化的使用,以及勇于尝试没有被证明过的方法。这种在处理统计问题上的新经验主义吸引了大量人才的涌入,使得大量实际问题有了更好的解决方案。尽管大部分情况下需要为深度学习修改甚至重新发明已经存在数十年的工具,但是这绝对是一件非常有意义并令人兴奋的事。

最后,深度学习社区长期以来以在学术界和企业之间分享工具而自豪,并开源了许多优秀的软件库、统计模型和预训练网络。正是本着开放开源的精神,本书的内容和基于它的教学视频可以自由下载和随意分享。我们致力于为所有人降低学习深度学习的门槛,并希望大家从中获益。

小结  

  • 机器学习研究如何使计算机系统利用经验改善性能。它是人工智能领域的分支,也是实现人工智能的一种手段。
  • 作为机器学习的一类,表征学习关注如何自动找出表示数据的合适方式。
  • 深度学习是具有多级表示的表征学习方法。它可以逐级表示越来越抽象的概念或模式。
  • 深度学习所基于的神经网络模型和用数据编程的核心思想实际上已经被研究了数百年。
  • 深度学习已经逐渐演变成一个工程师和科学家皆可使用的普适工具。

练习  

(1)你现在正在编写的代码有没有可以被“学习”的部分,也就是说,是否有可以被机器学习改进的部分?

(2)你在生活中有没有这样的场景:虽然有许多展示如何解决问题的样例,但却缺少自动解决问题的算法?它们也许是深度学习的最好猎物。

(3)如果把人工智能的发展看作是新一次工业革命,那么深度学习和数据的关系是否像是蒸汽机与煤炭的关系呢?为什么?

(4)端到端的训练方法还可以用在哪里?物理学、工程学还是经济学?

(5)为什么应该让深度网络模仿人脑结构?为什么不该让深度网络模仿人脑结构?

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e49084”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


从本章开始,我们将探索深度学习的奥秘。作为机器学习的一类,深度学习通常基于神经网络模型逐级表示越来越抽象的概念或模式。我们先从线性回归和softmax回归这两种单层神经网络入手,简要介绍机器学习中的基本概念。然后,我们由单层神经网络延伸到多层神经网络,并通过多层感知机引入深度学习模型。在观察和了解了模型的过拟合现象后,我们将介绍深度学习中应对过拟合的常用方法——权重衰减和丢弃法。接着,为了进一步理解深度学习模型训练的本质,我们将详细解释正向传播和反向传播。掌握这两个概念后,我们能更好地认识深度学习中的数值稳定性和初始化的一些问题。最后,我们通过一个深度学习应用案例对本章内容学以致用。

在本章的前几节,我们先介绍单层神经网络——线性回归和softmax回归。

扫码直达讨论区

线性回归输出是一个连续值,因此适用于回归问题。回归问题在实际中很常见,如预测房屋价格、气温、销售额等连续值的问题。与回归问题不同,分类问题中模型的最终输出是一个离散值。我们所说的图像分类、垃圾邮件识别、疾病检测等输出为离散值的问题都属于分类问题的范畴。softmax回归则适用于分类问题。

由于线性回归和softmax 回归都是单层神经网络,它们涉及的概念和技术同样适用于大多数的深度学习模型。我们首先以线性回归为例,介绍大多数深度学习模型的基本要素和表示方法。

我们以一个简单的房屋价格预测作为例子来解释线性回归的基本要素。这个应用的目标是预测一栋房子的售出价格(元)。我们知道这个价格取决于很多因素,如房屋状况、地段、市场行情等。为了简单起见,这里我们假设价格只取决于房屋状况的两个因素,即面积(平方米)和房龄(年)。接下来我们希望探索价格与这两个因素的具体关系。

1.模型

设房屋的面积为x1,房龄为x2,售出价格为y。我们需要建立基于输入x1x2来计算输出y的表达式,也就是模型(model)。顾名思义,线性回归假设输出与各个输入之间是线性关系:

其中权重(weight),b偏差(bias),且均为标量。它们是线性回归模型的参数(parameter)。模型输出是线性回归对真实价格 y的预测或估计。我们通常允许它们之间有一定误差。

2.模型训练

接下来我们需要通过数据来寻找特定的模型参数值,使模型在数据上的误差尽可能小。这个过程叫作模型训练(model training)。下面我们介绍模型训练所涉及的3个要素。

3.训练数据

我们通常收集一系列的真实数据,例如多栋房屋的真实售出价格和它们对应的面积和房龄。我们希望在这个数据上面寻找模型参数来使模型的预测价格与真实价格的误差最小。在机器学习术语里,该数据集被称为训练数据集(training data set)或训练集(training set),一栋房屋被称为一个样本(sample),其真实售出价格叫作标签(label),用来预测标签的两个因素叫作特征(feature)。特征用来表征样本的特点。

假设我们采集的样本数为n,索引为i的样本的特征为,标签为。对于索引为i的房屋,线性回归模型的房屋价格预测表达式为

4.损失函数

在模型训练中,我们需要衡量价格预测值与真实值之间的误差。通常我们会选取一个非负数作为误差,且数值越小表示误差越小。一个常用的选择是平方函数。它在评估索引为i 的样本误差的表达式为

其中常数1/2使对平方项求导后的常数系数为 1,这样在形式上稍微简单一些。显然,误差越小表示预测价格与真实价格越相近,且当二者相等时误差为 0。给定训练数据集,这个误差只与模型参数相关,因此我们将它记为以模型参数为参数的函数。在机器学习里,将衡量误差的函数称为损失函数(loss function)。这里使用的平方误差函数也称为平方损失(square loss)。

通常,我们用训练数据集中所有样本误差的平均来衡量模型预测的质量,即

在模型训练中,我们希望找出一组模型参数,记为,来使训练样本平均损失最小:

5.优化算法

当模型和损失函数形式较为简单时,上面的误差最小化问题的解可以直接用公式表达出来。这类解叫作解析解(analytical solution)。本节使用的线性回归和平方误差刚好属于这个范畴。然而,大多数深度学习模型并没有解析解,只能通过优化算法有限次迭代模型参数来尽可能降低损失函数的值。这类解叫作数值解(numerical solution)。

在求数值解的优化算法中,小批量随机梯度下降(mini-batch stochastic gradient descent)在深度学习中被广泛使用。它的算法很简单:先选取一组模型参数的初始值,如随机选取;接下来对参数进行多次迭代,使每次迭代都可能降低损失函数的值。在每次迭代中,先随机均匀采样一个由固定数目训练数据样本所组成的小批量(mini-batch),然后求小批量中数据样本的平均损失有关模型参数的导数(梯度),最后用此结果与预先设定的一个正数的乘积作为模型参数在本次迭代的减小量。

在训练本节讨论的线性回归模型的过程中,模型的每个参数将作如下迭代:

在上式中,代表每个小批量中的样本个数(批量大小,batch size),η称作学习率(learning rate)并取正数。需要强调的是,这里的批量大小和学习率的值是人为设定的,并不是通过模型训练学出的,因此叫作超参数(hyperparameter)。我们通常所说的“调参”指的正是调节超参数,例如通过反复试错来找到超参数合适的值。在少数情况下,超参数也可以通过模型训练学出。本书对此类情况不做讨论。

6.模型预测

模型训练完成后,我们将模型参数在优化算法停止时的值分别记作。注意,这里我们得到的并不一定是最小化损失函数的最优解,而是对最优解的一个近似。然后,我们就可以使用学出的线性回归模型来估算训练数据集以外任意一栋面积(平方米)为 、房龄(年)为的房屋的价格了。这里的估算也叫作模型预测模型推断模型测试

我们已经阐述了线性回归的模型表达式、训练和预测。下面我们解释线性回归与神经网络的联系,以及线性回归的矢量计算表达式。

1.神经网络图

在深度学习中,我们可以使用神经网络图直观地表现模型结构。为了更清晰地展示线性回归作为神经网络的结构,图3-1 使用神经网络图表示本节中介绍的线性回归模型。神经网络图隐去了模型参数权重和偏差。

图3-1 线性回归是一个单层神经网络

在图3-1所示的神经网络中,输入分别为x1x2,因此输入层的输入个数为 2。输入个数也叫特征数或特征向量维度。图3-1中网络的输出o输出层的输出个数为 1。需要注意的是,我们直接将图3-1 中神经网络的输出o作为线性回归的输出,即。由于输入层并不涉及计算,按照惯例,图3-1 所示的神经网络的层数为 1。所以,线性回归是一个单层神经网络。输出层中负责计算o的单元又叫神经元。在线性回归中,o的计算依赖于x1x2。也就是说,输出层中的神经元和输入层中各个输入完全连接。因此,这里的输出层又叫全连接层(fully-connected layer)或稠密层 ( dense layer)。

2.矢量计算表达式

在模型训练或预测时,我们常常会同时处理多个数据样本并用到矢量计算。在介绍线性回归的矢量计算表达式之前,让我们先考虑对两个向量相加的两种方法。

下面先定义两个 1 000 维的向量。

In [1]: from mxnet import nd
        from time import time

        a = nd.ones(shape=1000) 
        b = nd.ones(shape=1000)

向量相加的一种方法是,将这两个向量按元素逐一做标量加法。

In [2]: start = time()
        c = nd.zeros(shape=1000)
        for i in range(1000): 
            c[i] = a[i] + b[i]
        time() - start

Out[2]: 0.16967248916625977

向量相加的另一种方法是,将这两个向量直接做矢量加法。

In [3]: start = time()
        d = a + b 
        time() - start

Out[3]: 0.00031185150146484375

结果很明显,后者比前者更省时。因此,我们应该尽可能采用矢量计算,以提升计算效率。

让我们再次回到本节的房价预测问题。如果我们对训练数据集里的3个房屋样本(索引分别为1、2和3)逐一预测价格,将得到

现在,我们将上面3个等式转化成矢量计算。设

对 3 个房屋样本预测价格的矢量计算表达式为,其中的加法运算使用了广播机制(参见2.2节)。例如:

In [4]: a = nd.ones(shape=3) 
        b = 10
        a + b

Out[4]:
        [11. 11. 11.]
        <NDArray 3 @cpu(0)>

广义上讲,当数据样本数为n,特征数为d时,线性回归的矢量计算表达式

其中模型输出,批量数据样本特征,权重, 偏差。相应地,批量数据样本标签。设模型参数,我们可以重写损失函数为

小批量随机梯度下降的迭代步骤将相应地改写为

其中梯度是损失有关3个为标量的模型参数的偏导数组成的向量:

小结  

  • 和大多数深度学习模型一样,对于线性回归这样一种单层神经网络,它的基本要素包括模型、训练数据、损失函数和优化算法。
  • 既可以用神经网络图表示线性回归,又可以用矢量计算表示该模型。
  • 应该尽可能采用矢量计算,以提升计算效率。

练习

使用其他包(如 NumPy)或其他编程语言(如 MATLAB),比较相加两个向量的两种方法的运行时间。

扫码直达讨论区

在了解了线性回归的背景知识之后,现在我们可以动手实现它了。尽管强大的深度学习框架可以减少大量重复性工作,但若过于依赖它提供的便利,会导致我们很难深入理解深度学习是如何工作的。因此,本节将介绍如何只利用NDArrayautograd来实现一个线性回归的训练。

首先,导入本节中实验所需的包或模块,其中的matplotlib可用于作图,且设置成嵌入显示。

In [1]: %matplotlib inline
        from IPython import display
        from matplotlib import pyplot as plt
        from mxnet import autograd, nd
        import random

我们构造一个简单的人工训练数据集,它可以使我们能够直观比较学到的参数和真实的模型参数的区别。设训练数据集样本数为 1000,输入个数(特征数)为2。给定随机生成的批量样本特征 ,我们使用线性回归模型真实权重 和偏差,以及一个随机噪声项ϵ来生成标签

其中噪声项ϵ服从均值为 0、标准差为 0.01 的正态分布。噪声代表了数据集中无意义的干扰。下面,让我们生成数据集。

In [2]: num_inputs = 2
        num_examples = 1000
        true_w = [2, -3.4]
        true_b = 4.2
        features = nd.random.normal(scale=1, shape=(num_examples, num_inputs)) 
        labels = true_w[0] * features[:, 0] + true_w[1] * features[:, 1] + true_b 
        labels += nd.random.normal(scale=0.01, shape=labels.shape)

注意,features的每一行是一个长度为 2 的向量,而labels的每一行是一个长度为1的向量(标量)。

In [3]: features[0], labels[0]

Out[3]: (
         [2.2122064 0.7740038]
         <NDArray 2 @cpu(0)>,
         [6.000587]
         <NDArray 1 @cpu(0)>)

通过生成第二个特征features[:, 1]和标签labels的散点图,可以更直观地观察两者间的线性关系。

In [4]: def use_svg_display():
            # 用矢量图显示
            display.set_matplotlib_formats('svg')

        def set_f igsize(f igsize=(3.5, 2.5)): 
            use_svg_display()
            # 设置图的尺寸
            plt.rcParams['f igure.f igsize'] = f igsize
        set_f igsize()
        plt.scatter(features[:, 1].asnumpy(), labels.asnumpy(), 1); # 加分号只显示图

我们将上面的plt作图函数以及use_svg_display函数和set_f igsize函数定义在d2lzh包里。以后在作图时,我们将直接调用d2lzh.plt。由于pltd2lzh包中是一个全局变量,我们在作图前只需要调用d2lzh.set_figsize()即可打印矢量图并设置图的尺寸。

在训练模型的时候,我们需要遍历数据集并不断读取小批量数据样本。这里我们定义一个函数:它每次返回batch_size(批量大小)个随机样本的特征和标签。

In [5]: # 本函数已保存在d2lzh包中方便以后使用
        def data_iter(batch_size, features, labels): 
            num_examples = len(features)
            indices = list(range(num_examples)) 
            random.shuff le(indices) # 样本的读取顺序是随机的
            for i in range(0, num_examples, batch_size):
                j = nd.array(indices[i: min(i + batch_size, num_examples)])
                yield features.take(j), labels.take(j) # take函数根据索引返回对应元素

让我们读取第一个小批量数据样本并打印。每个批量的特征形状为(10, 2),分别对应批量大小和输入个数;标签形状为批量大小。

In [6]: batch_size = 10

        for X, y in data_iter(batch_size, features, labels):
            print(X, y)
            break


[[ 1.0876857  -1.7063738 ]
 [-0.51129895  0.46543437]
 [ 0.1533563  -0.735794  ]
 [ 0.3717077   0.9300072 ]
 [ 1.0115732  -0.83923554]
 [ 1.9738784   0.81172043]
 [-1.771029   -0.45138445]
 [ 0.7465509  -0.5054337 ]
 [-0.52480155  0.3005414 ]
 [ 0.5583534  -0.6039059 ]]
<NDArray 10x2 @cpu(0)>
[12.174357   1.6139998  6.9870367  1.7626053  9.06552   5.3893285
  2.1933131  7.4012175  2.1383817  7.379732 ]
<NDArray 10 @cpu(0)>

我们将权重初始化成均值为 0、标准差为 0.01 的正态随机数,偏差则初始化成 0。

In [7]: w = nd.random.normal(scale=0.01, shape=(num_inputs, 1))
        b = nd.zeros(shape=(1,))

之后的模型训练中,需要对这些参数求梯度来迭代参数的值,因此我们需要创建它们的梯度。

In [8]: w.attach_grad()
        b.attach_grad()

下面是线性回归的矢量计算表达式的实现。我们使用dot函数做矩阵乘法。

In [9]: def linreg(X, w, b): # 本函数已保存在d2lzh包中方便以后使用
            return nd.dot(X, w) + b

我们使用3.1节描述的平方损失来定义线性回归的损失函数。在实现中,我们需要把真实值y变形成预测值y_hat的形状。以下函数返回的结果也将和y_hat的形状相同。

In [10]: def squared_loss(y_hat, y): # 本函数已保存在d2lzh包中方便以后使用
             return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

以下的sgd函数实现了3.1节中介绍的小批量随机梯度下降算法。它通过不断迭代模型参数来优化损失函数。这里自动求梯度模块计算得来的梯度是一个批量样本的梯度和。我们将它除以批量大小来得到平均值。

In [11]: def sgd(params, lr, batch_size): # 本函数已保存在d2lzh包中方便以后使用
             for param in params:
                 param[:] = param - lr * param.grad / batch_size

在训练中,我们将多次迭代模型参数。在每次迭代中,我们根据当前读取的小批量数据样本(特征X和标签y),通过调用反向函数backward计算小批量随机梯度,并调用优化算法sgd迭代模型参数。由于我们之前设批量大小batch_size为 10,每个小批量的损失l的形状为(10, 1)。回忆一下2.3节。由于变量l并不是一个标量,运行l.backward()将对l中元素求和得到新的变量,再求该变量有关模型参数的梯度。

在一个迭代周期(epoch)中,我们将完整遍历一遍data_iter函数,并对训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。这里的迭代周期个数num_epochs和学习率lr都是超参数,分别设3和 0.03。在实践中,大多超参数都需要通过反复试错来不断调节。虽然迭代周期数设得越大模型可能越有效,但是训练时间可能过长。我们会在后面第7章中详细介绍学习率对模型的影响。

In [12]: lr = 0.03
         num_epochs = 3
         net = linreg
         loss = squared_loss

         for epoch in range(num_epochs): # 训练模型一共需要num_epochs个迭代周期
             # 在每一个迭代周期中, 会使用训练数据集中所有样本一次(假设样本数能够被批量大小整除)。X
             # 和y分别是小批量样本的特征和标签
             for X, y in data_iter(batch_size, features, labels):
                 with autograd.record():
                     l = loss(net(X, w, b), y) # l是有关小批量X和y的损失
                 l.backward() # 小批量的损失对模型参数求梯度
                 sgd([w, b], lr, batch_size) # 使用小批量随机梯度下降迭代模型参数
             train_l = loss(net(features, w, b), labels)
             print('epoch %d, loss %f' % (epoch + 1, train_l.mean().asnumpy()))

epoch 1, loss 0.040436
epoch 2, loss 0.000155
epoch 3, loss 0.000050 

训练完成后,我们可以比较学到的参数和用来生成训练集的真实参数。它们应该很接近。

In [13]: true_w, w

Out[13]: ([2, -3.4],
          [[ 1.9996936]
           [-3.3997262]]
         <NDArray 2x1 @cpu(0)>)

In [14]: true_b, b

Out[14]: (4.2,
          [4.199704]
          <NDArray 1 @cpu(0)>)

小结  

  • 可以看出,仅使用NDArrayautograd模块就可以很容易地实现一个模型。接下来,本书会在此基础上描述更多深度学习模型,并介绍怎样使用更简洁的代码(见3.3节)来实现它们。

练习

(1)为什么squared_loss函数中需要使用reshape函数?

(2)尝试使用不同的学习率,观察损失函数值的下降快慢。

(3)如果样本个数不能被批量大小整除,data_iter函数的行为会有什么变化?

扫码直达讨论区

随着深度学习框架的发展,开发深度学习应用变得越来越便利。实践中,我们通常可以用比3.2节更简洁的代码来实现同样的模型。在本节中,我们将介绍如何使用 MXNet 提供的Gluon接口更方便地实现线性回归的训练。

我们生成与3.2节中相同的数据集。其中features是训练数据特征,labels是标签。

In [1]: from mxnet import autograd, nd

        num_inputs = 2
        num_examples = 1000
        true_w = [2, -3.4]
        true_b = 4.2
        features = nd.random.normal(scale=1, shape=(num_examples, num_inputs)) 
        labels = true_w[0] * features[:, 0] + true_w[1] * features[:, 1] + true_b 
        labels += nd.random.normal(scale=0.01, shape=labels.shape)

Gluon 提供了data来读取数据。由于data常用作变量名,我们将导入的data模块用添加了 Gluon 首字母的假名gdata代替。在每一次迭代中,我们将随机读取包含 10 个数据样本的小批量。

In [2]: from mxnet.gluon import data as gdata

        batch_size = 10
        # 将训练数据的特征和标签组合
        dataset = gdata.ArrayDataset(features, labels)
        # 随机读取小批量
        data_iter = gdata.DataLoader(dataset, batch_size, shuff le=True) 

这里data_iter的使用与3.2节中的一样。让我们读取并打印第一个小批量数据样本。

In [3]: for X, y in data_iter:
            print(X, y)
            break


[[-1.4011667  -1.108803  ]
 [-0.4813231   0.5334126 ]
 [ 0.57794803  0.72061497]
 [ 1.1208912   1.2570045 ]
 [-0.2504259  -0.45037505]
 [ 0.08554042  0.5336134 ]
 [ 0.6347856   1.5795654 ]
 [-2.118665    3.3493772 ]
 [ 1.1353118   0.99125063]
 [-0.4814555  -0.91107726]]
<NDArray 10x2 @cpu(0)>
[  5.16208      1.4169512    2.9065104    2.164263    5.215756
   2.558468     0.09139667 -11.421704     3.1042643   6.332793 ]
<NDArray 10 @cpu(0)>

在3.2节从零开始的实现中,我们需要定义模型参数,并使用它们一步步描述模型是怎样计算的。当模型结构变得更复杂时,这些步骤将变得更烦琐。其实,Gluon 提供了大量预定义的层,这使我们只需关注使用哪些层来构造模型。下面将介绍如何使用 Gluon 更简洁地定义线性回归。

首先,导入nn模块。实际上,“nn”是 neural networks(神经网络)的缩写。顾名思义,该模块定义了大量神经网络的层。我们先定义一个模型变量net,它是一个 Sequential实例。在 Gluon 中,Sequential实例可以看作是一个串联各个层的容器。在构造模型时,我们在该容器中依次添加层。当给定输入数据时,容器中的每一层将依次计算并将输出作为下一层的输入。

In [4]: from mxnet.gluon import nn

        net = nn.Sequential()

回顾图3-1 中线性回归在神经网络图中的表示。作为一个单层神经网络,线性回归输出层中的神经元和输入层中各个输入完全连接。因此,线性回归的输出层又叫全连接层。在 Gluon 中,全连接层是一个Dense实例。我们定义该层输出个数为 1。

In [5]: net.add(nn.Dense(1))

值得一提的是,在 Gluon 中我们无须指定每一层输入的形状,例如线性回归的输入个数。当模型得到数据时,例如后面执行net(X)时,模型将自动推断出每一层的输入个数。我们将在第4章详细介绍这种机制。Gluon 的这一设计为模型开发带来便利。

在使用net前,我们需要初始化模型参数,如线性回归模型中的权重和偏差。我们从 MXNet 导入init模块。该模块提供了模型参数初始化的各种方法。这里的initinitializer的缩写形式。我们通过init.Normal(sigma=0.01)指定权重参数每个元素将在初始化时随机采样于均值为0、标准差为 0.01 的正态分布。偏差参数默认会初始化为零。

In [6]: from mxnet import init 

        net.initialize(init.Normal(sigma=0.01))

在 Gluon 中,loss模块定义了各种损失函数。我们用假名gloss代替导入的loss模块,并直接使用它提供的平方损失作为模型的损失函数。

In [7]: from mxnet.gluon import loss as gloss

        loss = gloss.L2Loss() # 平方损失又称L2范数损失

同样,我们也无须实现小批量随机梯度下降。在导入 Gluon 后,我们创建一个Trainer实例,并指定学习率为 0.03 的小批量随机梯度下降(sgd)为优化算法。该优化算法将用来迭代net实例所有通过add函数嵌套的层所包含的全部参数。这些参数可以通过collect_params函数获取。

In [8]: from mxnet import gluon

        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.03})

在使用 Gluon 训练模型时,我们通过调用Trainer实例的step函数来迭代模型参数。3.2节中我们提到,由于变量l是长度为batch_size的一维 NDArray,执行l.backward()等价于执行l.sum().backward()。按照小批量随机梯度下降的定义,我们在step函数中指明批量大小,从而对批量中样本梯度求平均。

In [9]: num_epochs = 3
        for epoch in range(1, num_epochs + 1):
            for X, y in data_iter:
                with autograd.record(): 
                    l = loss(net(X), y)
                l.backward() 
                trainer.step(batch_size)
            l = loss(net(features), labels)
            print('epoch %d, loss: %f' % (epoch, l.mean().asnumpy()))

epoch 1, loss: 0.040309
epoch 2, loss: 0.000153
epoch 3, loss: 0.000050

下面我们分别比较学到的模型参数和真实的模型参数。我们从net获得需要的层,并访问其权重(weight)和偏差(bias)。学到的模型参数和真实的参数很接近。

In [10]: dense = net[0]
         true_w, dense.weight.data()

Out[10]: ([2, -3.4],
          [[ 1.9996833 -3.3997345]]
          <NDArray 1x2 @cpu(0)>)

In [11]: true_b, dense.bias.data()

Out[11]: (4.2,
          [4.1996784]
          <NDArray 1 @cpu(0)>)

小结  

  • 使用 Gluon 可以更简洁地实现模型。  
  • 在 Gluon 中,data模块提供了有关数据处理的工具,nn模块定义了大量神经网络的层,loss模块定义了各种损失函数。  
  • MXNet 的initializer模块提供了模型参数初始化的各种方法。

练习

(1)如果将l = loss(net(X), y)替换成l = loss(net(X), y).mean(),我们需要将trainer.step(batch_size)相应地改成trainer.step(1)。这是为什么呢?

(2)查阅 MXNet 文档,看看gluon.lossinit模块里提供了哪些损失函数和初始化方法。

(3)如何访问dense.weight的梯度?

扫码直达讨论区

前几节介绍的线性回归模型适用于输出为连续值的情景。在另一类情景中,模型输出可以是一个像图像类别这样的离散值。对于这样的离散值预测问题,我们可以使用诸如 softmax回归在内的分类模型。和线性回归不同,softmax 回归的输出单元从一个变成了多个,且引入了 softmax 运算使输出更适合离散值的预测和训练。本节以 softmax 回归模型为例,介绍神经网络中的分类模型。

让我们考虑一个简单的图像分类问题,其输入图像的高和宽均为2像素,且色彩为灰度。这样每个像素值都可以用一个标量表示。我们将图像中的4像素分别记为。假设训练数据集中图像的真实标签为狗、猫或鸡(假设可以用 4像素表示出这3种动物),这些标签分别对应离散值

我们通常使用离散的数值来表示类别,例如。如此,一张图像的标签为 1、2 和 3 这3个数值中的一个。虽然我们仍然可以使用回归模型来进行建模,并将预测值就近定点化到 1、2 和 3 这3个离散值之一,但这种连续值到离散值的转化通常会影响到分类质量。因此我们一般使用更加适合离散值输出的模型来解决分类问题。

softmax 回归和线性回归一样将输入特征与权重做线性叠加。与线性回归的一个主要不同在于,softmax 回归的输出值个数等于标签里的类别数。因为一共有 4 种特征和 3 种输出动物类别,所以权重包含12 个标量(带下标的w)、偏差包含 3 个标量(带下标的b),且对每个输入计算这3个输出:

图3-2用神经网络图描绘了上面的计算。softmax 回归同线性回归一样,也是一个单层神经网络。由于每个输出的计算都要依赖于所有的输入,softmax 回归的输出层也是一个全连接层。

图3-2 softmax回归是一个单层神经网络

softmax运算

既然分类问题需要得到离散的预测输出,一个简单的办法是将输出值当作预测类别是i 的置信度,并将值最大的输出所对应的类作为预测输出,即输出。例如,如果分别为,由于最大,那么预测类别为2,其代表猫。

然而,直接使用输出层的输出有两个问题。一方面,由于输出层的输出值的范围不确定,我们难以直观上判断这些值的意义。例如,刚才举的例子中的输出值 10 表示“很置信”图像类别为猫,因为该输出值是其他两类的输出值的 100 倍。但如果,那么输出值 10 却又表示图像类别为猫的概率很低。另一方面,由于真实标签是离散值,这些离散值与不确定范围的输出值之间的误差难以衡量。

softmax 运算解决了以上两个问题。它通过下式将输出值变换成值为正且和为 1 的概率分布:

其中

容易看出,因此 是一个合法的概率分布。这时候,如果,不管的值是多少,我们都知道图像类别为猫的概率是 80%。此外,我们注意到

因此 softmax 运算不改变预测类别输出。

为了提高计算效率,我们可以将单样本分类通过矢量计算来表达。在上面的图像分类问题中,假设 softmax 回归的权重和偏差参数分别为

设高和宽分别为 2 个像素的图像样本i的特征为

输出层的输出为

预测为狗、猫或鸡的概率分布为

softmax 回归对样本i分类的矢量计算表达式为

为了进一步提升计算效率,我们通常对小批量数据做矢量计算。广义上讲,给定一个小批量样本,其批量大小为 n,输入个数(特征数)为d,输出个数(类别数)为q。设批量特征为。假设 softmax 回归的权重和偏差参数分别为。softmax 回归的矢量计算表达式为

其中的加法运算使用了广播机制, 且这两个矩阵的第 i行分别为样本i的输出 和概率分布

前面提到,使用 softmax 运算后可以更方便地与离散标签计算误差。我们已经知道,softmax 运算将输出变换成一个合法的类别预测分布。实际上,真实标签也可以用类别分布表达:对于样本i,我们构造向量,使其第(样本i 类别的离散数值)个元素为1,其余为0。这样我们的训练目标可以设为使预测概率分布 尽可能接近真实的标签概率分布

我们可以像线性回归那样使用平方损失函数。然而,想要预测分类结果正确,我们其实并不需要预测概率完全等于标签概率。例如,在图像分类的例子里,如果,那么我们只需要比其他两个预测值大就行了。即使值为 0.6,不管其他两个预测值为多少,类别预测均正确。而平方损失则过于严格,例如 的损失要小很多,虽然两者都有同样正确的分类预测结果。

改善上述问题的一个方法是使用更适合衡量两个概率分布差异的测量函数。其中,交叉熵(cross entropy)是一个常用的衡量方法:

其中带下标的是向量中非 0 即 1 的元素,需要注意将它与样本i 类别的离散数值,即不带下标的区分。在上式中,我们知道向量中只有第个元素为 1,其余全为0,于是。也就是说,交叉熵只关心对正确类别的预测概率,因为只要其值

足够大,就可以确保分类结果正确。当然,遇到一个样本有多个标签时,例如图像里含有不止一个物体时,我们并不能做这一步简化。但即便对于这种情况,交叉熵同样只关心对图像中出现的物体类别的预测概率。

假设训练数据集的样本数为n交叉熵损失函数定义为

其中代表模型参数。同样地,如果每个样本只有一个标签,那么交叉熵损失可以简写成。从另一个角度来看,我们知道最小化等价于最大化,即最小化交叉熵损失函数等价于最大化训练数据集所有标签类别的联合预测概率。

在训练好 softmax 回归模型后,给定任一样本特征,就可以预测每个输出类别的概率。通常,我们把预测概率最大的类别作为输出类别。如果它与真实类别(标签)一致,说明这次预测是正确的。在3.6节的实验中,我们将使用准确率(accuracy)来评价模型的表现。它等于正确预测数量与总预测数量之比。

小结  

  • softmax 回归适用于分类问题。它使用 softmax 运算输出类别的概率分布。  
  • softmax 回归是一个单层神经网络,输出个数等于分类问题中的类别个数。  
  • 交叉熵适合衡量两个概率分布的差异。

练习

查阅资料,了解最大似然估计。它与最小化交叉熵损失函数有哪些异曲同工之妙?

扫码直达讨论区

在介绍 softmax 回归的实现前我们先引入一个多类图像分类数据集。它将在后面的章节中被多次使用,以方便我们观察比较算法之间在模型精度和计算效率上的区别。图像分类数据集中最常用的是手写数字识别数据集MNIST。但大部分模型在 MNIST 上的分类精度都超过了 95%。为了更直观地观察算法之间的差异,我们将使用一个图像内容更加复杂的Fashion-MNIST数据集[60]

首先导入本节需要的包或模块。

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet.gluon import data as gdata
        import sys
        import time

下面,我们通过 Gluon 的data包来下载这个数据集。第一次调用时会自动从网上获取数据。我们通过参数train来指定获取训练数据集或测试数据集(testing data set)。测试数据集也叫测试集(testing set),只用来评价模型的表现,并不用来训练模型。

In [2]: mnist_train = gdata.vision.FashionMNIST(train=True) 
        mnist_test = gdata.vision.FashionMNIST(train=False)

训练集中和测试集中的每个类别的图像数分别为 6 000 和 1 000。因为有 10 个类别,所以训练集和测试集的样本数分别为 60 000 和 10 000。

In [3]: len(mnist_train), len(mnist_test)

Out[3]: (60000, 10000)

我们可以通过方括号[]来访问任意一个样本,下面获取第一个样本的图像和标签。

In [4]: feature, label = mnist_train[0]

变量feature对应高和宽均为 28 像素的图像。每个像素的数值为 0 到 255 之间 8 位无符号整数(uint8)。它使用三维的 NDArray存储,其中的最后一维是通道数。因为数据集中是灰度图像,所以通道数为1。为了表述简洁,我们将高和宽分别为hw像素的图像的形状记为或(h, w)。

In [5]: feature.shape, feature.dtype

Out[5]: ((28, 28, 1), numpy.uint8)

图像的标签使用 NumPy 的标量表示。它的类型为 32 位整数(int32)。

In [6]: label, type(label), label.dtype

Out[6]: (2, numpy.int32, dtype('int32'))

Fashion-MNIST 中一共包括了 10 个类别,分别为t-shirt(T 恤)、trouser(裤子)、pullover(套衫)、dress(连衣裙)、coat(外套)、sandal(凉鞋)、shirt(衬衫)、sneaker(运动鞋)、bag(包)和 ankle boot(短靴)。以下函数可以将数值标签转成相应的文本标签。

In [7]: # 本函数已保存在d2lzh包中方便以后使用
        def get_fashion_mnist_labels(labels):
            text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat', 
                           'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
            return [text_labels[int(i)] for i in labels]

下面定义一个可以在一行里画出多张图像和对应标签的函数。

In [8]: # 本函数已保存在d2lzh包中方便以后使用
        def show_fashion_mnist(images, labels): 
            d2l.use_svg_display()
            # 这里的_表示我们忽略(不使用)的变量
            _, f igs = d2l.plt.subplots(1, len(images), f igsize=(12, 12))
            for f, img, lbl in zip(f igs, images, labels): 
                f.imshow(img.reshape((28, 28)).asnumpy()) 
                f.set_title(lbl) 
                f.axes.get_xaxis().set_visible(False) 
                f.axes.get_yaxis().set_visible(False)

现在,我们看一下训练数据集中前 9 个样本的图像内容和文本标签。

In [9]: X, y = mnist_train[0:9]
        show_fashion_mnist(X, get_fashion_mnist_labels(y))

我们将在训练数据集上训练模型,并将训练好的模型在测试数据集上评价模型的表现。虽然我们可以像3.2节中那样通过yield来定义读取小批量数据样本的函数,但为了代码简洁,这里我们直接创建DataLoader实例。该实例每次读取一个样本数为batch_size的小批量数据。这里的批量大小batch_size是一个超参数。

在实践中,数据读取经常是训练的性能瓶颈,特别当模型较简单或者计算硬件性能较高时。Gluon 的DataLoader中一个很方便的功能是允许使用多进程来加速数据读取(暂不支持 Windows 操作系统)。这里我们通过参数num_workers来设置 4 个进程读取数据。

此外,我们通过ToTensor实例将图像数据从 uint8格式变换成 32 位浮点数格式,并除以 255 使得所有像素的数值均在 0 到 1 之间。ToTensor实例还将图像通道从最后一维移到最前一维来方便之后介绍的卷积神经网络计算。通过数据集的transform_f irst函数,我们将ToTensor的变换应用在每个数据样本(图像和标签)的第一个元素,即图像之上。

In [10]: batch_size = 256
         transformer = gdata.vision.transforms.ToTensor()
         if sys.platform.startswith('win'):
             num_workers = 0 # 0表示不用额外的进程来加速读取数据
         else:
             num_workers = 4

         train_iter = gdata.DataLoader(mnist_train.transform_f irst(transformer),
                                       batch_size, shuff le=True, 
                                       num_workers=num_workers)
         test_iter = gdata.DataLoader(mnist_test.transform_f irst(transformer),
                                      batch_size, shuff le=False, 
                                      num_workers=num_workers)

我们将获取并读取 Fashion-MNIST 数据集的逻辑封装在d2lzh.load_data_fashion_mnist函数中供后面章节调用。该函数将返回train_itertest_iter两个变量。随着本书内容的不断深入,我们会进一步改进该函数。它的完整实现将在5.6节中描述。

最后我们查看读取一遍训练数据需要的时间。

In [11]: start = time.time()
         for X, y in train_iter:
             continue
         '%.2f sec' % (time.time() - start)

Out[11]: '1.26 sec'

小结

  • Fashion-MNIST 是一个 10 类服饰分类数据集,之后章节里将使用它来检验不同算法的表现。  
  • 我们将高和宽分别为hw像素的图像的形状记为或(h, w)。

练习

(1)减小batch_size(如到1)会影响读取性能吗?

(2)非 Windows 用户请尝试修改num_workers来查看它对读取性能的影响。

(3)查阅 MXNet 文档,mxnet.gluon.data.vision里还提供了哪些别的数据集?

(4)查阅 MXNet 文档,mxnet.gluon.data.vision.transforms还提供了哪些别的变换方法?

扫码直达讨论区

这一节我们来动手实现 softmax 回归。首先导入本节实现所需的包或模块。

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet import autograd, nd

我们将使用 Fashion-MNIST 数据集,并设置批量大小为 256。

In [2]: batch_size = 256
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

跟线性回归中的例子一样,我们将使用向量表示每个样本。已知每个样本输入是高和宽均为 28 像素的图像。模型的输入向量的长度是28×28=784:该向量的每个元素对应图像中每个像素。由于图像有 10 个类别,单层神经网络输出层的输出个数为 10,因此softmax 回归的权重和偏差参数分别为 784×10 和1×10 的矩阵。

In [3]: num_inputs = 784
        num_outputs = 10

        W = nd.random.normal(scale=0.01, shape=(num_inputs, num_outputs)) 
        b = nd.zeros(num_outputs)

同之前一样,我们要为模型参数附上梯度。

In [4]: W.attach_grad()
        b.attach_grad()

在介绍如何定义 softmax 回归之前,我们先描述一下对如何对多维 NDArray 按维度操作。在下面的例子中,给定一个 NDArray矩阵X,我们可以只对其中同一列(axis=0)或同一行(axis=1)的元素求和,并在结果中保留行和列这两个维度(keepdims=True)。

In [5]: X = nd.array([[1, 2, 3], [4, 5, 6]])
        X.sum(axis=0, keepdims=True), X.sum(axis=1, keepdims=True)

Out[5]: (
         [[5. 7. 9.]]
         <NDArray 1x3 @cpu(0)>, 
         [[ 6.]
          [15.]]
         <NDArray 2x1 @cpu(0)>)

下面我们就可以定义3.4节介绍的 softmax 运算了。在下面的函数中,矩阵X的行数是样本数,列数是输出个数。为了表达样本预测各个输出的概率,softmax 运算会先通过exp函数对每个元素做指数运算,再对exp矩阵同行元素求和,最后令矩阵每行各元素与该行元素之和相除。这样一来,最终得到的矩阵每行元素和为 1 且非负。因此,该矩阵每行都是合法的概率分布。softmax 运算的输出矩阵中的任意一行元素代表了一个样本在各个输出类别上的预测概率。

In [6]: def softmax(X):
            X_exp = X.exp()
            partition = X_exp.sum(axis=1, keepdims=True)
            return X_exp / partition # 这里应用了广播机制

可以看到,对于随机输入,我们将每个元素变成了非负数,且每一行和为 1。

In [7]: X = nd.random.normal(shape=(2, 5)) 
        X_prob = softmax(X)
        X_prob, X_prob.sum(axis=1)

Out[7]: (
         [[0.21324193 0.33961776 0.1239742 0.27106097 0.05210521]
          [0.11462264 0.3461234 0.19401033 0.29583326 0.04941036]]
         <NDArray 2x5 @cpu(0)>, 
         [1.0000001 1.       ]
         <NDArray 2 @cpu(0)>)

有了 softmax 运算,我们可以定义3.4节描述的 softmax 回归模型了。这里通过reshape函数将每张原始图像改成长度为num_inputs的向量。

In [8]: def net(X):
            return softmax(nd.dot(X.reshape((-1, num_inputs)), W) + b)

在3.4节中,我们介绍了 softmax 回归使用的交叉熵损失函数。为了得到标签的预测概率,我们可以使用pick函数。在下面的例子中,变量y_hat是 2 个样本在 3 个类别的预测概率,变量y是这 2 个样本的标签类别。通过使用pick函数,我们得到了 2 个样本的标签的预测概率。与3.4节数学表述中标签类别离散值从 1 开始逐一递增不同,在代码中,标签类别的离散值是从 0 开始逐一递增的。

In [9]: y_hat = nd.array([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
        y = nd.array([0, 2], dtype='int32') 
        nd.pick(y_hat, y)

Out[9]:
        [0.1 0.5]
        <NDArray 2 @cpu(0)>

下面实现了3.4节中介绍的交叉熵损失函数。

In [10]: def cross_entropy(y_hat, y):
             return -nd.pick(y_hat, y).log()

给定一个类别的预测概率分布y_hat,我们把预测概率最大的类别作为输出类别。如果它与真实类别y一致,说明这次预测是正确的。分类准确率即正确预测数量与总预测数量之比。

为了演示准确率的计算,下面定义准确率accuracy函数。其中y_hat.argmax(axis=1)返回矩阵y_hat每行中最大元素的索引,且返回结果与变量y形状相同。我们在2.2节介绍过,相等条件判别式(y_hat.argmax(axis=1) == y)是一个值为 0(相等为假)或 1(相等为真)的NDArray。由于标签类型为整数,我们先将变量y变换为浮点数再进行相等条件判断。

In [11]: def accuracy(y_hat, y):
             return (y_hat.argmax(axis=1) == y.astype('f loat32')).mean().asscalar()

让我们继续使用在演示pick函数时定义的变量y_haty,并将它们分别作为预测概率分布和标签。可以看到,第一个样本预测类别为 2(该行最大元素 0.6 在本行的索引为 2),与真实标签 0 不一致;第二个样本预测类别为 2(该行最大元素 0.5 在本行的索引为 2),与真实标签 2 一致。因此,这两个样本上的分类准确率为 0.5。

In [12]: accuracy(y_hat, y)

Out[12]: 0.5

类似地,我们可以评价模型net在数据集data_iter上的准确率。

In [13]: # 本函数已保存在d2lzh包中方便以后使用。该函数将被逐步改进:它的完整实现将在9.1节中描述
         def evaluate_accuracy(data_iter, net): 
             acc_sum, n = 0.0, 0
             for X, y in data_iter:
                 y = y.astype('f loat32')
                 acc_sum += (net(X).argmax(axis=1) == y).sum().asscalar() 
                 n += y.size
             return acc_sum / n 

因为我们随机初始化了模型net,所以这个随机模型的准确率应该接近于类别个数 10 的倒数 0.1。

In [14]: evaluate_accuracy(test_iter, net)

Out[14]: 0.0925

训练 softmax 回归的实现与3.2节介绍的线性回归中的实现非常相似。我们同样使用小批量随机梯度下降来优化模型的损失函数。在训练模型时,迭代周期数num_epochs和学习率lr都是可以调的超参数。改变它们的值可能会得到分类更准确的模型。

In [15]: num_epochs, lr = 5, 0.1

         # 本函数已保存在d2lzh包中方便以后使用
         def train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, 
                       params=None, lr=None, trainer=None):
             for epoch in range(num_epochs):
                 train_l_sum, train_acc_sum, n = 0.0, 0.0, 0
                 for X, y in train_iter:
                     with autograd.record(): 
                         y_hat = net(X)
                         l = loss(y_hat, y).sum() 
                     l.backward()
                     if trainer is None:
                         d2l.sgd(params, lr, batch_size)
                     else:
                         trainer.step(batch_size) # 3.7节将用到
                     y = y.astype('f loat32') 
                     train_l_sum += l.asscalar()
                     train_acc_sum += (y_hat.argmax(axis=1) == y).sum().asscalar() 
                     n += y.size
                 test_acc = evaluate_accuracy(test_iter, net)
                 print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f'
                       % (epoch + 1, train_l_sum / n, train_acc_sum / n, test_acc))

         train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, batch_size, 
                  [W, b], lr)

epoch 1, loss 0.7882, train acc 0.749, test acc 0.800
epoch 2, loss 0.5741, train acc 0.811, test acc 0.824
epoch 3, loss 0.5298, train acc 0.823, test acc 0.830
epoch 4, loss 0.5055, train acc 0.830, test acc 0.834
epoch 5, loss 0.4887, train acc 0.834, test acc 0.840

训练完成后,现在就可以演示如何对图像进行分类了。给定一系列图像(第三行图像输出),我们比较一下它们的真实标签(第一行文本输出)和模型预测结果(第二行文本输出)。

In [16]: for X, y in test_iter:
             break

         true_labels = d2l.get_fashion_mnist_labels(y.asnumpy())
         pred_labels = d2l.get_fashion_mnist_labels(net(X).argmax(axis=1).asnumpy())
         titles = [true + '\n' + pred for true, pred in zip(true_labels, pred_labels)]

         d2l.show_fashion_mnist(X[0:9], titles[0:9])

小结  

  • 可以使用 softmax 回归做多类别分类。与训练线性回归相比,你会发现训练 softmax 回归的步骤和它非常相似:获取并读取数据、定义模型和损失函数并使用优化算法训练模型。事实上,绝大多数深度学习模型的训练都有着类似的步骤。

练习

(1)在本节中,我们直接按照 softmax 运算的数学定义来实现 softmax 函数。这可能会造成什么问题?(提示:试一试计算exp(50)的大小。)

(2)本节中的cross_entropy函数是按照3.4节中的交叉熵损失函数的数学定义实现的。这样的实现方式可能有什么问题?(提示:思考一下对数函数的定义域。)

(3)你能想到哪些办法来解决上面的两个问题?

扫码直达讨论区

我们在3.3节中已经了解了使用 Gluon 实现模型的便利。下面,让我们再次使用 Gluon 来实现一个 softmax 回归模型。首先导入所需的包或模块。

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet import gluon, init
        from mxnet.gluon import loss as gloss, nn

我们仍然使用 Fashion-MNIST 数据集和3.6节中设置的批量大小。

In [2]: batch_size = 256
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

在3.4节中提到,softmax 回归的输出层是一个全连接层。因此,我们添加一个输出个数为 10 的全连接层。我们使用均值为 0、标准差为 0.01 的正态分布随机初始化模型的权重参数。

In [3]: net = nn.Sequential() 
        net.add(nn.Dense(10)) 
        net.initialize(init.Normal(sigma=0.01))

如果做了3.6节的练习,那么你可能意识到了分开定义 softmax 运算和交叉熵损失函数可能会造成数值不稳定。因此,Gluon 提供了一个包括 softmax 运算和交叉熵损失计算的函数。它的数值稳定性更好。

In [4]: loss = gloss.SoftmaxCrossEntropyLoss()

我们使用学习率为 0.1 的小批量随机梯度下降作为优化算法。

In [5]: trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.1})

接下来,我们使用3.6节中定义的训练函数来训练模型。

In [6]: num_epochs = 5
        d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, None,
                     None, trainer)

epoch 1, loss 0.7885, train acc 0.747, test acc 0.806
epoch 2, loss 0.5741, train acc 0.811, test acc 0.824
epoch 3, loss 0.5293, train acc 0.824, test acc 0.832
epoch 4, loss 0.5042, train acc 0.831, test acc 0.838
epoch 5, loss 0.4892, train acc 0.835, test acc 0.841

小结 

  • Gluon 提供的函数往往具有更好的数值稳定性。  

  • 可以使用 Gluon 更简洁地实现 softmax 回归。

练习 

尝试调一调超参数,如批量大小、迭代周期和学习率,看看结果会怎样。

扫码直达讨论区

我们已经介绍了包括线性回归和 softmax 回归在内的单层神经网络。然而深度学习主要关注多层模型。在本节中,我们将以多层感知机(multilayer perceptron,MLP)为例,介绍多层神经网络的概念。

多层感知机在单层神经网络的基础上引入了一到多个隐藏层(hidden layer)。隐藏层位于输入层和输出层之间。图3-3展示了一个多层感知机的神经网络图。

图3-3 带有隐藏层的多层感知机。它含有一个隐藏层,该层中有 5 个隐藏单元

在图3-3所示的多层感知机中,输入和输出个数分别为 4 和 3,中间的隐藏层中包含了 5 个隐藏单元(hidden unit)。由于输入层不涉及计算,图3-3 中的多层感知机的层数为 2。由图3-3 可见,隐藏层中的神经元和输入层中各个输入完全连接,输出层中的神经元和隐藏层中的各个神经元也完全连接。因此,多层感知机中的隐藏层和输出层都是全连接层。

具体来说,给定一个小批量样本,其批量大小为 n,输入个数为d。假设多层感知机只有一个隐藏层,其中隐藏单元个数为h。记隐藏层的输出(也称为隐藏层变量隐藏变量)为,有。因为隐藏层和输出层均是全连接层,可以设隐藏层的权重参数和偏差参数分别为,输出层的权重和偏差参数分别为

我们先来看一种含单隐藏层的多层感知机的设计。其输出 的计算为

也就是将隐藏层的输出直接作为输出层的输入。如果将以上两个式子联立起来,可以得到

从联立后的式子可以看出,虽然神经网络引入了隐藏层,却依然等价于一个单层神经网络:其中输出层权重参数为,偏差参数为。不难发现,即便再添加更多的隐藏层,以上设计依然只能与仅含输出层的单层神经网络等价。

上述问题的根源在于全连接层只是对数据做仿射变换(aff ine transformation),而多个仿射变换的叠加仍然是一个仿射变换。解决问题的一个方法是引入非线性变换,例如对隐藏变量使用按元素运算的非线性函数进行变换,然后再作为下一个全连接层的输入。这个非线性函数被称为激活函数(activation function)。下面我们介绍几个常用的激活函数。

1.ReLU函数

ReLU(rectif ied linear unit)函数提供了一个很简单的非线性变换。给定元素x,该函数定义为

可以看出,ReLU函数只保留正数元素,并将负数元素清零。为了直观地观察这一非线性变换,我们先定义一个绘图函数xyplot

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet import autograd, nd

        def xyplot(x_vals, y_vals, name): 
            d2l.set_f igsize(f igsize=(5, 2.5)) 
            d2l.plt.plot(x_vals.asnumpy(), y_vals.asnumpy()) 
            d2l.plt.xlabel('x')
            d2l.plt.ylabel(name + '(x)')

我们接下来通过NDArray提供的relu函数来绘制 ReLU 函数。可以看到,该激活函数是一个两段线性函数。

In [2]: x = nd.arange(-8.0, 8.0, 0.1) 
        x.attach_grad()
        with autograd.record(): 
            y = x.relu()
        xyplot(x, y, 'relu')

显然,当输入为负数时,ReLU 函数的导数为 0;当输入为正数时,ReLU 函数的导数为 1。尽管输入为 0 时 ReLU 函数不可导,但是我们可以取此处的导数为 0。下面绘制ReLU 函数的导数。

In [3]: y.backward()
        xyplot(x, x.grad, 'grad of relu')

2.sigmoid函数

sigmoid函数可以将元素的值变换到0和1之间:

sigmoid 函数在早期的神经网络中较为普遍,但它目前逐渐被更简单的 ReLU 函数取代。在第6章中我们会介绍如何利用它值域在 0 到 1 之间这一特性来控制信息在神经网络中的流动。下面绘制了 sigmoid 函数。当输入接近 0 时,sigmoid 函数接近线性变换。

In [4]: with autograd.record():
            y = x.sigmoid() 
        xyplot(x, y, 'sigmoid')

依据链式法则,sigmoid 函数的导数为

下面绘制了 sigmoid 函数的导数。当输入为 0 时,sigmoid 函数的导数达到最大值 0.25;当输入越偏离 0 时,sigmoid 函数的导数越接近 0。

In [5]: y.backward()
        xyplot(x, x.grad, 'grad of sigmoid')

3.tanh函数

tanh(双曲正切函数可以将元素的值变换到 -1 和 1 之间:

我们接着绘制 tanh函数。当输入接近 0 时,tanh 函数接近线性变换。虽然该函数的形状和 sigmoid 函数的形状很像,但 tanh 函数在坐标系的原点上对称。

In [6]: with autograd.record():
            y = x.tanh() 
        xyplot(x, y, 'tanh')

依据链式法则,tanh 函数的导数为

下面绘制了 tanh 函数的导数。当输入为 0 时,tanh 函数的导数达到最大值 1;当输入越偏离 0 时,tanh 函数的导数越接近 0。

In [7]: y.backward()
        xyplot(x, x.grad, 'grad of tanh')

多层感知机就是含有至少一个隐藏层的由全连接层组成的神经网络,且每个隐藏层的输出通过激活函数进行变换。多层感知机的层数和各隐藏层中隐藏单元个数都是超参数。以单隐藏层为例并沿用本节之前定义的符号,多层感知机按以下方式计算输出:

其中φ表示激活函数。在分类问题中,我们可以对输出O 做softmax 运算,并使用 softmax 回归中的交叉熵损失函数。在回归问题中,我们将输出层的输出个数设为1,并将输出O 直接提供给线性回归中使用的平方损失函数。

小结  

  • 多层感知机在输出层与输入层之间加入了一个或多个全连接隐藏层,并通过激活函数对隐藏层输出进行变换。  
  • 常用的激活函数包括 ReLU 函数、sigmoid 函数和 tanh 函数。

练习

(1)应用链式法则,推导出 sigmoid 函数和 tanh 函数的导数的数学表达式。

(2)查阅资料,了解其他的激活函数。

扫码直达讨论区

我们已经从3.8节里了解了多层感知机的原理。下面,我们一起来动手实现一个多层感知机。首先导入实现所需的包或模块。

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet import nd
        from mxnet.gluon import loss as gloss

这里继续使用 Fashion-MNIST 数据集。我们将使用多层感知机对图像进行分类。

In [2]: batch_size = 256
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

我们在3.5节里已经介绍了,Fashion-MNIST 数据集中图像形状为28×28,类别数为 10。本节中我们依然使用长度为28×28=784 的向量表示每一张图像。因此,输入个数为784,输出个数为10。实验中,我们设超参数隐藏单元个数为256。

In [3]: num_inputs, num_outputs, num_hiddens = 784, 10, 256

        W1 = nd.random.normal(scale=0.01, shape=(num_inputs, num_hiddens)) 
        b1 = nd.zeros(num_hiddens)
        W2 = nd.random.normal(scale=0.01, shape=(num_hiddens, num_outputs)) 
        b2 = nd.zeros(num_outputs)
        params = [W1, b1, W2, b2]

        for param in params: 
            param.attach_grad()

这里我们使用基础的maximum函数来实现 ReLU,而非直接调用MXNet的relu函数。

In [4]: def relu(X):
            return nd.maximum(X, 0)

同 softmax 回归一样,我们通过reshape函数将每张原始图像改成长度为num_inputs的向量。然后我们实现3.8节中多层感知机的计算表达式。

In [5]: def net(X):
            X = X.reshape((-1, num_inputs)) 
            H = relu(nd.dot(X, W1) + b1) 
            return nd.dot(H, W2) + b2

为了得到更好的数值稳定性,我们直接使用 Gluon 提供的包括 softmax 运算和交叉熵损失计算的函数。

In [6]: loss = gloss.SoftmaxCrossEntropyLoss()

训练多层感知机的步骤和3.6节中训练 softmax 回归的步骤没什么区别。我们直接调用d2lzh包中的train_ch3函数,它的实现已经在3.6节里介绍过。我们在这里设超参数迭代周期数为 5,学习率为 0.5。

In [7]: num_epochs, lr = 5, 0.5
        d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, 
                      params, lr)

epoch 1, loss 0.7941, train acc 0.704, test acc 0.817
epoch 2, loss 0.4859, train acc 0.821, test acc 0.846
epoch 3, loss 0.4289, train acc 0.840, test acc 0.864
epoch 4, loss 0.3949, train acc 0.855, test acc 0.867
epoch 5, loss 0.3717, train acc 0.863, test acc 0.873

小结  

  • 可以通过手动定义模型及其参数来实现简单的多层感知机。  

  • 当多层感知机的层数较多时,本节的实现方法会显得较烦琐,如在定义模型参数的时候。

练习

(1)改变超参数num_hiddens的值,看看对实验结果有什么影响。

(2)试着加入一个新的隐藏层,看看对实验结果有什么影响。

扫码直达讨论区

下面我们使用 Gluon 来实现3.9节中的多层感知机。首先导入所需的包或模块。

In [1]: import d2lzh as d2l
        from mxnet import gluon, init
        from mxnet.gluon import loss as gloss, nn

和 softmax 回归唯一的不同在于,我们多加了一个全连接层作为隐藏层。它的隐藏单元个数为 256,并使用 ReLU 函数作为激活函数。

In [2]: net = nn.Sequential() 
        net.add(nn.Dense(256, activation='relu'),
                nn.Dense(10)) 
        net.initialize(init.Normal(sigma=0.01))

我们使用与3.7节中训练 softmax 回归几乎相同的步骤来读取数据并训练模型。

In [3]: batch_size = 256
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

        loss = gloss.SoftmaxCrossEntropyLoss()
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.5}) 
        num_epochs = 5
        d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, None, 
                      None, trainer)

epoch 1, loss 0.8033, train acc 0.701, test acc 0.819
epoch 2, loss 0.4998, train acc 0.815, test acc 0.836
epoch 3, loss 0.4332, train acc 0.838, test acc 0.862
epoch 4, loss 0.4019, train acc 0.851, test acc 0.855
epoch 5, loss 0.3755, train acc 0.862, test acc 0.873

小结  

  • 通过 Gluon可以更简洁地实现多层感知机。

练习

(1)尝试多加入几个隐藏层,对比3.9节中从零开始的实现。

(2)使用其他的激活函数,看看对结果的影响。

扫码直达讨论区

在前几节基于 Fashion-MNIST 数据集的实验中,我们评价了机器学习模型在训练数据集和测试数据集上的表现。如果你改变过实验中的模型结构或者超参数,你也许发现了:当模型在训练数据集上更准确时,它在测试数据集上却不一定更准确。这是为什么呢?

在解释上述现象之前,我们需要区分训练误差(training error)和泛化误差(generalization error)。通俗来讲,前者指模型在训练数据集上表现出的误差,后者指模型在任意一个测试数据样本上表现出的误差的期望,并常常通过测试数据集上的误差来近似。计算训练误差和泛化误差可以使用之前介绍过的损失函数,例如线性回归用到的平方损失函数和 softmax 回归用到的交叉熵损失函数。

让我们以高考为例来直观地解释训练误差和泛化误差这两个概念。训练误差可以认为是做往年高考试题(训练题)时的错误率,泛化误差则可以通过真正参加高考(测试题)时的答题错误率来近似。假设训练题和测试题都随机采样于一个未知的依照相同考纲的巨大试题库。如果让一名未学习中学知识的小学生去答题,那么测试题和训练题的答题错误率可能很相近。但如果换成一名反复练习训练题的高三备考生答题,即使在训练题上做到了错误率为 0,也不代表真实的高考成绩会如此。

在机器学习里,我们通常假设训练数据集(训练题)和测试数据集(测试题)里的每一个样本都是从同一个概率分布中相互独立地生成的。基于该独立同分布假设,给定任意一个机器学习模型(含参数),它的训练误差的期望和泛化误差都是一样的。例如,如果我们将模型参数设成随机值(小学生),那么训练误差和泛化误差会非常相近。但我们从前面几节中已经了解到,模型的参数是通过在训练数据集上训练模型而学习出的,参数的选择依据了最小化训练误差(高三备考生)。所以,训练误差的期望小于或等于泛化误差。也就是说,一般情况下,由训练数据集学到的模型参数会使模型在训练数据集上的表现优于或等于在测试数据集上的表现。由于无法从训练误差估计泛化误差,一味地降低训练误差并不意味着泛化误差一定会降低。

机器学习模型应关注降低泛化误差。

在机器学习中,通常需要评估若干候选模型的表现并从中选择模型。这一过程称为模型选择(model selection)。可供选择的候选模型可以是有着不同超参数的同类模型。以多层感知机为例,我们可以选择隐藏层的个数,以及每个隐藏层中隐藏单元个数和激活函数。为了得到有效的模型,我们通常要在模型选择上下一番功夫。下面,我们来描述模型选择中经常使用的验证数据集(validation data set)。

1.验证数据集

从严格意义上讲,测试集只能在所有超参数和模型参数选定后使用一次。不可以使用测试数据选择模型,如调参。由于无法从训练误差估计泛化误差,因此也不应只依赖训练数据选择模型。鉴于此,我们可以预留一部分在训练数据集和测试数据集以外的数据来进行模型选择。这部分数据被称为验证数据集,简称验证集(validation set)。例如,我们可以从给定的训练集中随机选取一小部分作为验证集,而将剩余部分作为真正的训练集。

然而在实际应用中,由于数据不容易获取,测试数据极少只使用一次就丢弃。因此,实践中验证数据集和测试数据集的界限可能比较模糊。从严格意义上讲,除非明确说明,否则本书中实验所使用的测试集应为验证集,实验报告的测试结果(如测试准确率)应为验证结果(如验证准确率)。

2.k折交叉验证

由于验证数据集不参与模型训练,当训练数据不够用时,预留大量的验证数据显得太奢侈。一种改善的方法是k折交叉验证k-fold cross-validation)。在k折交叉验证中,我们把原始训练数据集分割成 k 个不重合的子数据集,然后我们做k次模型训练和验证。每一次,我们使用一个子数据集验证模型,并使用其他k-1个子数据集来训练模型。在这k 次训练和验证中,每次用来验证模型的子数据集都不同。最后,我们对这k次训练误差和验证误差分别求平均。

接下来,我们将探究模型训练中经常出现的两类典型问题:一类是模型无法得到较低的训练误差,我们将这一现象称作欠拟合(underf itting);另一类是模型的训练误差远小于它在测试数据集上的误差,我们称该现象为过拟合(overf itting)。在实践中,我们要尽可能同时应对欠拟合和过拟合。虽然有很多因素可能导致这两种拟合问题,在这里我们重点讨论两个因素:模型复杂度和训练数据集大小。

1.模型复杂度

为了解释模型复杂度,我们以多项式函数拟合为例。给定一个由标量数据特征x和对应的标量标签y 组成的训练数据集,多项式函数拟合的目标是找一个K阶多项式函数

来近似y。在上式中,是模型的权重参数,b 是偏差参数。与线性回归相同,多项式函数拟合也使用平方损失函数。特别地,一阶多项式函数拟合又叫线性函数拟合。

因为高阶多项式函数模型参数更多,模型函数的选择空间更大,所以高阶多项式函数比低阶多项式函数的复杂度更高。因此,高阶多项式函数比低阶多项式函数更容易在相同的训练数据集上得到更低的训练误差。给定训练数据集,模型复杂度和误差之间的关系通常如图3-4所示。给定训练数据集,如果模型的复杂度过低,很容易出现欠拟合;如果模型复杂度过高,很容易出现过拟合。应对欠拟合和过拟合的一个办法是针对数据集选择合适复杂度的模型。

2.训练数据集大小

影响欠拟合和过拟合的另一个重要因素是训练数据集的大小。一般来说,如果训练数据集中样本数过少,特别是比模型参数数量(按元素计)更少时,过拟合更容易发生。此外,泛化误差不会随训练数据集里样本数量增加而增大。因此,在计算资源允许的范围之内,我们通常希望训练数据集大一些,特别是在模型复杂度较高时,如层数较多的深度学习模型。

图3-4 模型复杂度对欠拟合和过拟合的影响

为了理解模型复杂度和训练数据集大小对欠拟合和过拟合的影响,下面我们以多项式函数拟合为例来实验。首先导入实验需要的包或模块。

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet import autograd, gluon, nd
        from mxnet.gluon import data as gdata, loss as gloss, nn

1.生成数据集

我们将生成一个人工数据集。在训练数据集和测试数据集中,给定样本特征x,我们使用如下的三阶多项式函数来生成该样本的标签:

其中噪声项ϵ服从均值为 0 、标准差为 0.1 的正态分布。训练数据集和测试数据集的样本数都设为 100。

In [2]: n_train, n_test, true_w, true_b = 100, 100, [1.2, -3.4, 5.6], 5 
        features = nd.random.normal(shape=(n_train + n_test, 1)) 
        poly_features = nd.concat(features, nd.power(features, 2),
                                  nd.power(features, 3))
        labels = (true_w[0] * poly_features[:, 0] + true_w[1] * poly_features[:, 1]
                  + true_w[2] * poly_features[:, 2] + true_b) 
        labels += nd.random.normal(scale=0.1, shape=labels.shape)

看一看生成的数据集的前两个样本。

In [3]: features[:2], poly_features[:2], labels[:2]

Out[3]: (
         [[2.2122064]
          [0.7740038]]
         <NDArray 2x1 @cpu(0)>,
         [[ 2.2122064   4.893857   10.826221 ]
          [ 0.7740038   0.5990819   0.46369165]]
         <NDArray 2x3 @cpu(0)>, 
         [51.674885   6.3585763]
         <NDArray 2 @cpu(0)>)

2.定义、训练和测试模型

我们先定义作图函数semilogy,其中 y轴使用了对数尺度。

In [4]: # 本函数已保存在d2lzh包中方便以后使用
        def semilogy(x_vals, y_vals, x_label, y_label, x2_vals=None, y2_vals=None, 
                     legend=None, f igsize=(3.5, 2.5)):
            d2l.set_f igsize(f igsize) 
            d2l.plt.xlabel(x_label) 
            d2l.plt.ylabel(y_label) 
            d2l.plt.semilogy(x_vals, y_vals) 
            if x2_vals and y2_vals:
                d2l.plt.semilogy(x2_vals, y2_vals, linestyle=':') 
                d2l.plt.legend(legend)

和线性回归一样,多项式函数拟合也使用平方损失函数。因为我们将尝试使用不同复杂度的模型来拟合生成的数据集,所以我们把模型定义部分放在fit_and_plot函数中。多项式函数拟合的训练和测试步骤与3.6节介绍的 softmax 回归中的相关步骤类似。

In [5]: num_epochs, loss = 100, gloss.L2Loss()

        def f it_and_plot(train_features, test_features, train_labels, test_labels): 
            net = nn.Sequential()
            net.add(nn.Dense(1)) 
            net.initialize()
            batch_size = min(10, train_labels.shape[0]) 
            train_iter = gdata.DataLoader(gdata.ArrayDataset(
                train_features, train_labels), batch_size, shuff le=True) 
            trainer = gluon.Trainer(net.collect_params(), 'sgd',
                                    {'learning_rate': 0.01}) 
            train_ls, test_ls = [], []
            for _ in range(num_epochs):
                for X, y in train_iter:
                    with autograd.record(): 
                        l = loss(net(X), y)
                    l.backward() 
                    trainer.step(batch_size)
                train_ls.append(loss(net(train_features),
                                     train_labels).mean().asscalar()) 
                test_ls.append(loss(net(test_features),
                                    test_labels).mean().asscalar())
            print('f inal epoch: train loss', train_ls[-1], 'test loss', test_ls[-1]) 
            semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'loss',
                     range(1, num_epochs + 1), test_ls, ['train', 'test'])
            print('weight:', net[0].weight.data().asnumpy(),
                  '\nbias:', net[0].bias.data().asnumpy())

3.三阶多项式函数拟合(正常)

我们先使用与数据生成函数同阶的三阶多项式函数拟合。实验表明,这个模型的训练误差和在测试数据集的误差都较低。训练出的模型参数也接近真实值:

In [6]: f it_and_plot(poly_features[:n_train, :], poly_features[n_train:, :],
                     labels[:n_train], labels[n_train:])

f inal epoch: train loss 0.007049637 test loss 0.0119097745 
weight: [[ 1.3258897 -3.363281   5.561593 ]]
bias: [4.9517436]

4.线性函数拟合(欠拟合)

我们再试试线性函数拟合。很明显,该模型的训练误差在迭代早期下降后便很难继续降低。在完成最后一次迭代周期后,训练误差依旧很高。线性模型在非线性模型(如三阶多项式函数)生成的数据集上容易欠拟合。

In [7]: f it_and_plot(features[:n_train, :], features[n_train:, :], labels[:n_train], 
                     labels[n_train:])

f inal epoch: train loss 43.997887 test loss 160.65588 
weight: [[15.577538]]
bias: [2.2902575]

5.训练样本不足(过拟合)

事实上,即便使用与数据生成模型同阶的三阶多项式函数模型,如果训练样本不足,该模型依然容易过拟合。让我们只使用两个样本来训练模型。显然,训练样本过少了,甚至少于模型参数的数量。这使模型显得过于复杂,以至于容易被训练数据中的噪声影响。在迭代过程中,尽管训练误差较低,但是测试数据集上的误差却很高。这是典型的过拟合现象。

In [8]: f it_and_plot(poly_features[0:2, :], poly_features[n_train:, :], labels[0:2], 
                    labels[n_train:])

f inal epoch: train loss 0.4027369 test loss 103.314186 
weight: [[1.3872364 1.9376589 3.5085924]]
bias: [1.2312856]

我们将在3.12节和3.13节继续讨论过拟合问题以及应对过拟合的方法。

小结

  • 由于无法从训练误差估计泛化误差,一味地降低训练误差并不意味着泛化误差一定会降低。机器学习模型应关注降低泛化误差。  
  • 可以使用验证数据集来进行模型选择。  
  • 欠拟合指模型无法得到较低的训练误差,过拟合指模型的训练误差远小于它在测试数据集上的误差。  
  • 应选择复杂度合适的模型并避免使用过少的训练样本。

练习

(1)如果用一个三阶多项式模型来拟合一个线性模型生成的数据,可能会有什么问题?为什么?

(2)在本节提到的三阶多项式拟合问题里,有没有可能把 100 个样本的训练误差的期望降到 0,为什么?(提示:考虑噪声的存在。)

扫码直达讨论区

3.11节中我们观察了过拟合现象,即模型的训练误差远小于它在测试集上的误差。虽然增大训练数据集可能会减轻过拟合,但是获取额外的训练数据往往代价高昂。本节介绍应对过拟合问题的常用方法——权重衰减(weight decay)。

权重衰减等价于范数正则化(regularization)。正则化通过为模型损失函数添加惩罚项使学出的模型参数值较小,是应对过拟合的常用手段。我们先描述范数正则化,再解释它为何又称权重衰减。

范数正则化在模型原损失函数基础上添加 范数惩罚项,从而得到训练所需要最小化的函数。范数惩罚项指的是模型权重参数每个元素的平方和与一个正的常数的乘积。以3.1节中的线性回归损失函数

为例,其中 , 是权重参数,b 是偏差参数,样本 i 的输入为,标签为,样本数为n。将权重参数用向量表示,带有 范数惩罚项的新损失函数为

其中超参数。当权重参数均为 0 时,惩罚项最小。当较大时,惩罚项在损失函数中的比重较大,这通常会使学到的权重参数的元素较接近 0。当设为 0 时,惩罚项完全不起作用。上式中范数平方展开后得到。有了范数惩罚项后,在小批量随机梯度下降中,我们将3.1节中权重的迭代方式更改为

可见,范数正则化令权重先自乘小于 1 的数,再减去不含惩罚项的梯度。因此, 范数正则化又叫权重衰减。权重衰减通过惩罚绝对值较大的模型参数为需要学习的模型增加了限制,这可能对过拟合有效。实际场景中,我们有时也在惩罚项中添加偏差元素的平方和。

下面,我们以高维线性回归为例来引入一个过拟合问题,并使用权重衰减来应对过拟合。设数据样本特征的维度为p。对于训练数据集和测试数据集中特征为的任一样本,我们使用如下的线性函数来生成该样本的标签:

其中噪声项ϵ服从均值为 0、标准差为 0.01 的正态分布。为了较容易地观察过拟合,我们考虑高维线性回归问题,如设维度p=200;同时,我们特意把训练数据集的样本数设低,如 20。

In [1]: %matplotlib inline
        import d2lzh as d2l
        from mxnet import autograd, gluon, init, nd
        from mxnet.gluon import data as gdata, loss as gloss, nn

        n_train, n_test, num_inputs = 20, 100, 200
        true_w, true_b = nd.ones((num_inputs, 1)) * 0.01, 0.05

        features = nd.random.normal(shape=(n_train + n_test, num_inputs)) 
        labels = nd.dot(features, true_w) + true_b
        labels += nd.random.normal(scale=0.01, shape=labels.shape)
        train_features, test_features = features[:n_train, :], features[n_train:, :] 
        train_labels, test_labels = labels[:n_train], labels[n_train:]

下面先介绍从零开始实现权重衰减的方法。我们通过在目标函数后添加L2范数惩罚项来实现权重衰减。

1.初始化模型参数

首先,定义随机初始化模型参数的函数。该函数为每个参数都附上梯度。

In [2]: def init_params():
            w = nd.random.normal(scale=1, shape=(num_inputs, 1)) 
            b = nd.zeros(shape=(1,))
            w.attach_grad() 
            b.attach_grad() 
            return [w, b]

2.定义 L2范数惩罚项

下面定义L2范数惩罚项。这里只惩罚模型的权重参数。

In [3]: def l2_penalty(w):
            return (w**2).sum() / 2

3.定义训练和测试

下面定义如何在训练数据集和测试数据集上分别训练和测试模型。与前面几节中不同的是,这里在计算最终的损失函数时添加了L2范数惩罚项。

In [4]: batch_size, num_epochs, lr = 1, 100, 0.003
        net, loss = d2l.linreg, d2l.squared_loss 
        train_iter = gdata.DataLoader(gdata.ArrayDataset(
            train_features, train_labels), batch_size, shuff le=True)

        def f it_and_plot(lambd): 
            w, b = init_params()
            train_ls, test_ls = [], []
            for _ in range(num_epochs):
                for X, y in train_iter:
                    with autograd.record():
                        # 添加了L2范数惩罚项
                        l = loss(net(X, w, b), y) + lambd * l2_penalty(w) 
                    l.backward()
                    d2l.sgd([w, b], lr, batch_size) 
                train_ls.append(loss(net(train_features, w, b),
                                     train_labels).mean().asscalar()) 
                test_ls.append(loss(net(test_features, w, b),
                                    test_labels).mean().asscalar())
            d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'loss',
                         range(1, num_epochs + 1), test_ls, ['train', 'test'])
            print('L2 norm of w:', w.norm().asscalar())

4.观察过拟合

接下来,让我们训练并测试高维线性回归模型。当lambd设为 0 时,我们没有使用权重衰减。结果训练误差远小于测试集上的误差。这是典型的过拟合现象。

In [5]: f it_and_plot(lambd=0)

L2 norm of w: 11.611939

5.使用权重衰减

下面我们使用权重衰减。可以看出,训练误差虽然有所提高,但测试集上的误差有所下降。过拟合现象得到一定程度的缓解。另外,权重参数的范数比不使用权重衰减时的更小,此时的权重参数更接近 0。

In [6]: f it_and_plot(lambd=3)

L2 norm of w: 0.041881386

这里我们直接在构造Trainer实例时通过wd参数来指定权重衰减超参数。默认下,Gluon 会对权重和偏差同时衰减。我们可以分别对权重和偏差构造Trainer实例,从而只对权重衰减。

In [7]: def f it_and_plot_gluon(wd):
            net = nn.Sequential() 
            net.add(nn.Dense(1)) 
            net.initialize(init.Normal(sigma=1))
            # 对权重参数衰减。权重名称一般是以weight结尾
            trainer_w = gluon.Trainer(net.collect_params('.*weight'), 'sgd',
                                      {'learning_rate': lr, 'wd': wd})
            # 不对偏差参数衰减。偏差名称一般是以bias结尾
            trainer_b = gluon.Trainer(net.collect_params('.*bias'), 'sgd',
                                      {'learning_rate': lr})
            train_ls, test_ls = [], []
            for _ in range(num_epochs):
               for X, y in train_iter:
                   with autograd.record(): 
                       l = loss(net(X), y)
                   l.backward()
                   # 对两个Trainer实例分别调用step函数, 从而分别更新权重和偏差 
                   trainer_w.step(batch_size) 
                   trainer_b.step(batch_size)
               train_ls.append(loss(net(train_features),
                                    train_labels).mean().asscalar()) 
               test_ls.append(loss(net(test_features),
                                   test_labels).mean().asscalar()) 
           d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'loss',
                        range(1, num_epochs + 1), test_ls, ['train', 'test'])
           print('L2 norm of w:', net[0].weight.data().norm().asscalar()) 

与从零开始实现权重衰减的实验现象类似,使用权重衰减可以在一定程度上缓解过拟合问题。

In [8]: f it_and_plot_gluon(0)

L2 norm of w: 13.311797

In [9]: f it_and_plot_gluon(3)

L2 norm of w: 0.032021914

小结  

  • 正则化通过为模型损失函数添加惩罚项使学出的模型参数值较小,是应对过拟合的常用手段。  
  • 权重衰减等价于 范数正则化,通常会使学到的权重参数的元素较接近 0。  
  • 权重衰减可以通过 Gluon 的wd超参数来指定。  
  • 可以定义多个Trainer实例对不同的模型参数使用不同的迭代方法。

练习

(1)回顾一下训练误差和泛化误差的关系。除了权重衰减、增大训练量以及使用复杂度合适的模型,你还能想到哪些办法来应对过拟合?

(2)如果你了解贝叶斯统计,你觉得权重衰减对应贝叶斯统计里的哪个重要概念?

(3)调节实验中的权重衰减超参数,观察并分析实验结果。

扫码直达讨论区

除了3.12节介绍的权重衰减以外,深度学习模型常常使用丢弃法(dropout)[49] 来应对过拟合问题。丢弃法有一些不同的变体。本节中提到的丢弃法特指倒置丢弃法(inverted dropout)。

回忆一下,3.8节的图3-3描述了一个含单隐藏层的多层感知机。其中输入个数为 4,隐藏单元个数为 5,且隐藏单元()的计算表达式为

这里是激活函数,是输入,隐藏单元i的权重参数为,偏差参数为。当对该隐藏层使用丢弃法时,该层的隐藏单元将有一定概率被丢弃掉。设丢弃概率p,那么有p的概率 会被清零,有的概率 会除以做拉伸。丢弃概率是丢弃法的超参数。具体来说,设随机变量为0和1的概率分别为p。使用丢弃法时我们计算新的隐藏单元

由于,因此

即丢弃法不改变其输入的期望值。让我们对图3-3中的隐藏层使用丢弃法,一种可能的结果如图3-5所示,其中被清零。这时输出值的计算不再依赖,在反向传播时,与这两个隐藏单元相关的权重的梯度均为 0。由于在训练中隐藏层神经元的丢弃是随机的,即 都有可能被清零,输出层的计算无法过度依赖中的任一个,从而在训练模型时起到正则化的作用,并可以用来应对过拟合。在测试模型时,我们为了得到更加确定性的结果,一般不使用丢弃法。

图3-5 隐藏层使用了丢弃法的多层感知机

根据丢弃法的定义,我们可以很容易地实现它。下面的dropout函数将以drop_prob的概率丢弃NDArray 输入X中的元素。

In [1]: import d2lzh as d2l
        from mxnet import autograd, gluon, init, nd
        from mxnet.gluon import loss as gloss, nn

        def dropout(X, drop_prob): 
            assert 0 <= drop_prob <= 1 
            keep_prob = 1 - drop_prob
            # 这种情况下把全部元素都丢弃
            if keep_prob == 0:
                return X.zeros_like()
            mask = nd.random.uniform(0, 1, X.shape) < keep_prob
            return mask * X / keep_prob 

我们运行几个例子来测试一下dropout函数,其中丢弃概率分别为 0、0.5 和 1。

In [2]: X = nd.arange(16).reshape((2, 8))
        dropout(X, 0)

Out[2]:
        [[ 0. 1.  2.  3.  4.  5.  6.  7.]
         [ 8. 9. 10. 11. 12. 13. 14. 15.]]
        <NDArray 2x8 @cpu(0)>

In [3]: dropout(X, 0.5)

Out[3]:
        [[ 0.  2. 4. 6.  0.  0.  0. 14.]
         [ 0. 18. 0. 0. 24. 26. 28.  0.]]
        <NDArray 2x8 @cpu(0)>

In [4]: dropout(X, 1)

Out[4]:
        [[0. 0. 0. 0. 0. 0. 0. 0.]
         [0. 0. 0. 0. 0. 0. 0. 0.]]
        <NDArray 2x8 @cpu(0)>

1.定义模型参数

实验中,我们依然使用3.5节中介绍的 Fashion-MNIST 数据集。我们将定义一个包含两个隐藏层的多层感知机,其中两个隐藏层的输出个数都是 256。

In [5]: num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

        W1 = nd.random.normal(scale=0.01, shape=(num_inputs, num_hiddens1)) 
        b1 = nd.zeros(num_hiddens1)
        W2 = nd.random.normal(scale=0.01, shape=(num_hiddens1, num_hiddens2)) 
        b2 = nd.zeros(num_hiddens2)
        W3 = nd.random.normal(scale=0.01, shape=(num_hiddens2, num_outputs)) 
        b3 = nd.zeros(num_outputs)
        params = [W1, b1, W2, b2, W3, b3]

        for param in params: 
            param.attach_grad()

2.定义模型

下面定义的模型将全连接层和激活函数ReLU串起来,并对每个激活函数的输出使用丢弃法。我们可以分别设置各个层的丢弃概率。通常的建议是把靠近输入层的丢弃概率设得小一点。在这个实验中,我们把第一个隐藏层的丢弃概率设为0.2,把第二个隐藏层的丢弃概率设为0.5。我们可以通过2.3节中介绍的is_training函数来判断运行模式为训练还是测试,并只需在训练模式下使用丢弃法。

In [6]: drop_prob1, drop_prob2 = 0.2, 0.5

        def net(X):
            X = X.reshape((-1, num_inputs))
            H1 = (nd.dot(X, W1) + b1).relu()
            if autograd.is_training(): # 只在训练模型时使用丢弃法
                H1 = dropout(H1, drop_prob1) # 在第一层全连接后添加丢弃层
            H2 = (nd.dot(H1, W2) + b2).relu()
            if autograd.is_training():
                H2 = dropout(H2, drop_prob2) # 在第二层全连接后添加丢弃层
            return nd.dot(H2, W3) + b3

3.训练和测试模型

这部分与之前多层感知机的训练和测试类似。

In [7]: num_epochs, lr, batch_size = 5, 0.5, 256
        loss = gloss.SoftmaxCrossEntropyLoss()
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size) 
        d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size,
                      params, lr)

epoch 1, loss 1.2260, train acc 0.526, test acc 0.759
epoch 2, loss 0.6336, train acc 0.765, test acc 0.795
epoch 3, loss 0.5147, train acc 0.812, test acc 0.845
epoch 4, loss 0.4648, train acc 0.830, test acc 0.861
epoch 5, loss 0.4362, train acc 0.840, test acc 0.852

在 Gluon 中,我们只需要在全连接层后添加Dropout层并指定丢弃概率。在训练模型时,Dropout层将以指定的丢弃概率随机丢弃上一层的输出元素;在测试模型时,Dropout层并不发挥作用。

In [8]: net = nn.Sequential() 
        net.add(nn.Dense(256, activation="relu"),
                nn.Dropout(drop_prob1), # 在第一个全连接层后添加丢弃层
                nn.Dense(256, activation="relu"), 
                nn.Dropout(drop_prob2), # 在第二个全连接层后添加丢弃层 
                nn.Dense(10))
        net.initialize(init.Normal(sigma=0.01))

下面训练并测试模型。

In [9]: trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, None,
                     None, trainer)

epoch 1, loss 1.1863, train acc 0.542, test acc 0.765
epoch 2, loss 0.5867, train acc 0.782, test acc 0.839
epoch 3, loss 0.4947, train acc 0.821, test acc 0.857
epoch 4, loss 0.4476, train acc 0.839, test acc 0.865
epoch 5, loss 0.4224, train acc 0.845, test acc 0.864

小结  

  • 我们可以通过使用丢弃法应对过拟合。  
  • 丢弃法只在训练模型时使用。

练习

(1)如果把本节中的两个丢弃概率超参数对调,会有什么结果?

(2)增大迭代周期数,比较使用丢弃法与不使用丢弃法的结果。

(3)如果将模型改得更加复杂,如增加隐藏层单元,使用丢弃法应对过拟合的效果是否更加明显?

(4)以本节中的模型为例,比较使用丢弃法与权重衰减的效果。如果同时使用丢弃法和权重衰减,效果会如何?

扫码直达讨论区

前面几节里我们使用了小批量随机梯度下降的优化算法来训练模型。在实现中,我们只提供了模型的正向传播的计算,即对输入计算模型输出,然后通过autograd模块来调用系统自动生成的backward函数计算梯度。基于反向传播算法的自动求梯度极大简化了深度学习模型训练算法的实现。本节我们将使用数学来描述正向传播和反向传播。具体来说,我们将以带范数正则化的含单隐藏层的多层感知机为样例模型解释正向传播和反向传播。

正向传播(forward-propagation)是指对神经网络沿着从输入层到输出层的顺序,依次计算并存储模型的中间变量(包括输出)。为简单起见,假设输入是一个特征为的样本,且不考虑偏差项,那么中间变量

其中 是隐藏层的权重参数。把中间变量输入按元素运算的激活函数后,将得到向量长度为h的隐藏层变量

隐藏层变量h也是一个中间变量。假设输出层参数只有权重,可以得到向量长度为q的输出层变量

假设损失函数为,且样本标签为 y,可以计算出单个数据样本的损失项

根据范数正则化的定义,给定超参数λ,正则化项即

其中矩阵的Frobenius范数等价于将矩阵变平为向量后计算范数。最终,模型在给定的数据样本上带正则化的损失为

我们将J称为有关给定数据样本的目标函数,并在以下的讨论中简称目标函数。

我们通常绘制计算图(computational graph)来可视化运算符和变量在计算中的依赖关系。图3-6绘制了本节中样例模型正向传播的计算图,其中左下角是输入,右上角是输出。可以看到,图中箭头方向大多是向右和向上,其中方框代表变量,圆圈代表运算符,箭头表示从输入到输出之间的依赖关系。

图3-6 正向传播的计算图

反向传播(back-propagation)指的是计算神经网络参数梯度的方法。总的来说,反向传播依据微积分中的链式法则,沿着从输出层到输入层的顺序,依次计算并存储目标函数有关神经网络各层的中间变量以及参数的梯度。对输入或输出X, Y, Z为任意形状张量的函数,通过链式法则,我们有

其中prod运算符将根据两个输入的形状,在必要的操作(如转置和互换输入位置)后对两个输入做乘法。

回顾一下本节中样例模型,它的参数是,因此反向传播的目标是计算。我们将应用链式法则依次计算各中间变量和参数的梯度,其计算次序与前向传播中相应中间变量的计算次序恰恰相反。首先,分别计算目标函数有关损失项L和正则项s的梯度

其次,依据链式法则计算目标函数有关输出层变量的梯度

接下来,计算正则项有关两个参数的梯度:

现在,我们可以计算最靠近输出层的模型参数的梯度。依据链式法则,得到

沿着输出层向隐藏层继续反向传播,隐藏层变量的梯度可以这样计算:

由于激活函数是按元素运算的,中间变量的梯度 的计算需要使用按元素乘法符

最终,我们可以得到最靠近输入层的模型参数的梯度。依据链式法则,得到

在训练深度学习模型时,正向传播和反向传播之间相互依赖。下面我们仍然以本节中的样例模型分别阐述它们之间的依赖关系。

一方面,正向传播的计算可能依赖于模型参数的当前值,而这些模型参数是在反向传播的梯度计算后通过优化算法迭代的。例如,计算正则化项依赖模型参数 的当前值,而这些当前值是优化算法最近一次根据反向传播算出梯度后迭代得到的。

另一方面,反向传播的梯度计算可能依赖于各变量的当前值,而这些变量的当前值是通过正向传播计算得到的。举例来说,参数梯度的计算需要依赖隐藏层变量的当前值h。这个当前值是通过从输入层到输出层的正向传播计算并存储得到的。

因此,在模型参数初始化完成后,我们交替地进行正向传播和反向传播,并根据反向传播计算的梯度迭代模型参数。既然我们在反向传播中使用了正向传播中计算得到的中间变量来避免重复计算,那么这个复用也导致正向传播结束后不能立即释放中间变量内存。这也是训练要比预测占用更多内存的一个重要原因。另外需要指出的是,这些中间变量的个数大体上与网络层数线性相关,每个变量的大小与批量大小和输入个数也是线性相关的,它们是导致较深的神经网络使用较大批量训练时更容易超内存的主要原因。

小结  

  • 正向传播沿着从输入层到输出层的顺序,依次计算并存储神经网络的中间变量。  
  • 反向传播沿着从输出层到输入层的顺序,依次计算并存储神经网络的中间变量和参数的梯度。  
  • 在训练深度学习模型时,正向传播和反向传播相互依赖。

练习

在本节样例模型的隐藏层和输出层中添加偏差参数,修改计算图以及正向传播和反向传播的数学表达式。

扫码直达讨论区

理解了正向传播与反向传播以后,我们来讨论一下深度学习模型的数值稳定性问题以及模型参数的初始化方法。深度模型有关数值稳定性的典型问题是衰减(vanishing)和爆炸(explosion)。

当神经网络的层数较多时,模型的数值稳定性容易变差。假设一个层数为L 的多层感知机的第l的权重参数为,输出层的权重参数为。为了便于讨论,不考虑偏差参数,且设所有隐藏层的激活函数为恒等映射(identity mapping)。给定输入,多层感知机的第l层的输出。此时,如果层数l较大,的计算可能会出现衰减或爆炸。举个例子,假设输入和所有层的权重参数都是标量,如权重参数为0.2和5,多层感知机的第 30 层输出为输入分别与0.230≈1×10-21(衰减)和530≈9×1020(爆炸)的乘积。类似地,当层数较多时,梯度的计算也更容易出现衰减或爆炸。

随着内容的不断深入,我们会在后面的章节进一步介绍深度学习的数值稳定性问题以及解决方法。

在神经网络中,通常需要随机初始化模型参数。下面我们来解释这样做的原因。

回顾3.8节图3-3 描述的多层感知机。为了方便解释,假设输出层只保留一个输出单元o1(删去o2o3以及指向它们的箭头),且隐藏层使用相同的激活函数。如果将每个隐藏单元的参数都初始化为相等的值,那么在正向传播时每个隐藏单元将根据相同的输入计算出相同的值,并传递至输出层。在反向传播中,每个隐藏单元的参数梯度值相等。因此,这些参数在使用基于梯度的优化算法迭代后值依然相等。之后的迭代也是如此。在这种情况下,无论隐藏单元有多少,隐藏层本质上只有 1 个隐藏单元在发挥作用。因此,正如在前面的实验中所做的那样,我们通常对神经网络的模型参数,特别是权重参数,进行随机初始化。

1.MXNet的默认随机初始化

随机初始化模型参数的方法有很多。在3.3节中,我们使用net.initialize(init.Normal(sigma=0.01))使模型net的权重参数采用正态分布的随机初始化方式。如果不指定初始化方法,如net.initialize(),MXNet 将使用默认的随机初始化方法:权重参数每个元素随机采样于-0.07 到0.07之间的均匀分布,偏差参数全部清零。

2.Xavier随机初始化

还有一种比较常用的随机初始化方法叫作Xavier随机初始化[16]。假设某全连接层的输入个数为a,输出个数为b,Xavier 随机初始化将使该层中权重参数的每个元素都随机采样于均匀分布

它的设计主要考虑到,模型参数初始化后,每层输出的方差不该受该层输入个数影响,且每层梯度的方差也不该受该层输出个数影响。

小结  

  • 深度模型有关数值稳定性的典型问题是衰减和爆炸。当神经网络的层数较多时,模型的数值稳定性容易变差。  
  • 我们通常需要随机初始化神经网络的模型参数,如权重参数。

练习

(1)有人说随机初始化模型参数是为了“打破对称性”。这里的“对称”应如何理解?

(2)是否可以将线性回归或 softmax 回归中所有的权重参数都初始化为相同值?

扫码直达讨论区

作为深度学习基础篇章的总结,我们将对本章内容学以致用。下面,让我们动手实战一个 Kaggle 比赛——房价预测。本节将提供未经调优的数据的预处理、模型的设计和超参数的选择。我们希望读者通过动手操作、仔细观察实验现象、认真分析实验结果并不断调整方法,得到令自己满意的结果。

Kaggle是一个著名的供机器学习爱好者交流的平台。图3-7展示了 Kaggle 网站的首页。为了便于提交结果,需要注册 Kaggle 账号。

图3-7 Kaggle网站的首页

我们可以在房价预测比赛的网页上了解比赛信息和参赛者成绩,也可以下载数据集并提交自己的预测结果。该比赛的网页地址是https://www.kaggle.com/c/house-prices-advanced-regression-techniques。

图3-8展示了房价预测比赛的网页信息。

图3-8 房价预测比赛的网页信息。比赛数据集可通过点击“Data”标签获取

比赛数据分为训练数据集和测试数据集。两个数据集都包括每栋房子的特征,如街道类型、建造年份、房顶类型、地下室状况等特征值。这些特征值有连续的数字、离散的标签甚至是缺失值“na”。只有训练数据集包括了每栋房子的价格,也就是标签。我们可以访问比赛网页,点击图3-8中的“Data”标签,并下载这些数据集。

我们将通过pandas库读取并处理数据。在导入本节需要的包前请确保已安装pandas库,否则请参考下面的代码注释。

In [1]: # 如果没有安装pandas, 则反注释下面一行
        # !pip install pandas

        %matplotlib inline
        import d2lzh as d2l
        from mxnet import autograd, gluon, init, nd
        from mxnet.gluon import data as gdata, loss as gloss, nn
        import numpy as np
        import pandas as pd 

解压后的数据位于../data目录,它包括两个 csv 文件。下面使用pandas读取这两个文件。

In [2]: train_data = pd.read_csv('../data/kaggle_house_pred_train.csv')
        test_data = pd.read_csv('../data/kaggle_house_pred_test.csv')

训练数据集包括 1 460 个样本、80 个特征和 1 个标签。

In [3]: train_data.shape

Out[3]: (1460, 81)

测试数据集包括 1 459 个样本和 80 个特征。我们需要将测试数据集中每个样本的标签预测出来。

In [4]: test_data.shape

Out[4]: (1459, 80)

让我们来查看前 4 个样本的前 4 个特征、后 2 个特征和标签(SalePrice):

In [5]: train_data.iloc[0:4, [0, 1, 2, 3, -3, -2, -1]]

Out[5]:    Id MSSubClass MSZoning LotFrontage SaleType SaleCondition SalePrice
        0   1         60       RL        65.0       WD        Normal    208500
        1   2         20       RL        80.0       WD        Normal    181500
        2   3         60       RL        68.0       WD        Normal    223500
        3   4         70       RL        60.0       WD       Abnorml    140000

可以看到第一个特征是 Id,它能帮助模型记住每个训练样本,但难以推广到测试样本,所以我们不使用它来训练。我们将所有的训练数据和测试数据的79个特征按样本连结。

In [6]: all_features = pd.concat((train_data.iloc[:, 1:-1], test_data.iloc[:, 1:]))

我们对连续数值的特征做标准化(standardization):设该特征在整个数据集上的均值为µ,标准差为σ。那么,我们可以将该特征的每个值先减去µ再除以σ得到标准化后的每个特征值。对于缺失的特征值,我们将其替换成该特征的均值。

In [7]: numeric_features = all_features.dtypes[all_features.dtypes != 'object'].index 
        all_features[numeric_features] = all_features[numeric_features].apply(
            lambda x: (x - x.mean()) / (x.std()))
        # 标准化后, 每个特征的均值变为0, 所以可以直接用0来替换缺失值
        all_features[numeric_features] = all_features[numeric_features].f illna(0)

接下来将离散数值转成指示特征。举个例子,假设特征 MSZoning 里面有两个不同的离散值 RL 和 RM,那么这一步转换将去掉 MSZoning 特征,并新加两个特征 MSZoning_RL 和 MSZoning_RM,其值为 0 或 1。如果一个样本原来在 MSZoning 里的值为 RL,那么有 MSZoning_RL = 1 且 MSZoning_RM = 0。

In [8]: # dummy_na=True将缺失值也当作合法的特征值并为其创建指示特征
        all_features = pd.get_dummies(all_features, dummy_na=True) 
        all_features.shape

Out[8]: (2919, 331)

可以看到这一步转换将特征数从 79 增加到了 331。

最后,通过values属性得到NumPy 格式的数据,并转成NDArray方便后面的训练。

In [9]: n_train = train_data.shape[0]
        train_features = nd.array(all_features[:n_train].values)
        test_features = nd.array(all_features[n_train:].values)
        train_labels = nd.array(train_data.SalePrice.values).reshape((-1, 1))

我们使用一个基本的线性回归模型和平方损失函数来训练模型。

In [10]: loss = gloss.L2Loss()

         def get_net():
             net = nn.Sequential() 
             net.add(nn.Dense(1)) 
             net.initialize() 
             return net

下面定义比赛用来评价模型的对数均方根误差。给定预测值和对应的真实标签,它的定义为

对数均方根误差的实现如下:

In [11]: def log_rmse(net, features, labels):
             # 将小于1的值设成1, 使得取对数时数值更稳定
             clipped_preds = nd.clip(net(features), 1, f loat('inf'))
             rmse = nd.sqrt(2 * loss(clipped_preds.log(), labels.log()).mean())
             return rmse.asscalar()

下面的训练函数与本章中前几节的不同在于使用了 Adam 优化算法。相对之前使用的小批量随机梯度下降,它对学习率相对不那么敏感。我们将在7.8节详细介绍它。

In [12]: def train(net, train_features, train_labels, test_features, test_labels, 
                   num_epochs, learning_rate, weight_decay, batch_size):
             train_ls, test_ls = [], []
             train_iter = gdata.DataLoader(gdata.ArrayDataset( 
                 train_features, train_labels), batch_size, shuff le=True)
             # 这里使用了Adam优化算法
             trainer = gluon.Trainer(net.collect_params(), 'adam', {
                 'learning_rate': learning_rate, 'wd': weight_decay})
             for epoch in range(num_epochs):
                 for X, y in train_iter:
                     with autograd.record(): 
                         l = loss(net(X), y)
                     l.backward() 
                     trainer.step(batch_size)
                 train_ls.append(log_rmse(net, train_features, train_labels))
                 if test_labels is not None:
                     test_ls.append(log_rmse(net, test_features, test_labels))
             return train_ls, test_ls

我们在3.11节中介绍了k折交叉验证。它将被用来选择模型设计并调节超参数。下面实现了一个函数,它返回第i折交叉验证时所需要的训练和验证数据。

In [13]: def get_k_fold_data(k, i, X, y):
             assert k > 1
             fold_size = X.shape[0] // k 
             X_train, y_train = None, None 
             for j in range(k):
                 idx = slice(j * fold_size, (j + 1) * fold_size) 
                 X_part, y_part = X[idx, :], y[idx]
                 if j == i:
                     X_valid, y_valid = X_part, y_part
                 elif X_train is None:
                     X_train, y_train = X_part, y_part
                 else:
                     X_train = nd.concat(X_train, X_part, dim=0) 
                     y_train = nd.concat(y_train, y_part, dim=0)
             return X_train, y_train, X_valid, y_valid 

k折交叉验证中我们训练k 次并返回训练和验证的平均误差。

In [14]: def k_fold(k, X_train, y_train, num_epochs,
                    learning_rate, weight_decay, batch_size): 
             train_l_sum, valid_l_sum = 0, 0
             for i in range(k):
                 data = get_k_fold_data(k, i, X_train, y_train) 
                 net = get_net()
                 train_ls, valid_ls = train(net, *data, num_epochs, learning_rate,
                                            weight_decay, batch_size) 
                 train_l_sum += train_ls[-1]
                 valid_l_sum += valid_ls[-1]
                 if i == 0:
                     d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'rmse',
                                  range(1, num_epochs + 1), valid_ls, 
                                  ['train', 'valid'])
                 print('fold %d, train rmse %f, valid rmse %f'
                       % (i, train_ls[-1], valid_ls[-1]))
             return train_l_sum / k, valid_l_sum / k

我们使用一组未经调优的超参数并计算交叉验证误差。可以改动这些超参数来尽可能减小平均测试误差。

In [15]: k, num_epochs, lr, weight_decay, batch_size = 5, 100, 5, 0, 64
         train_l, valid_l = k_fold(k, train_features, train_labels, num_epochs, lr,
                                   weight_decay, batch_size)
         print('%d-fold validation: avg train rmse %f, avg valid rmse %f'
               % (k, train_l, valid_l))

fold 0, train rmse 0.169686, valid rmse 0.157010
fold 1, train rmse 0.162097, valid rmse 0.187972
fold 2, train rmse 0.163778, valid rmse 0.168125
fold 3, train rmse 0.167723, valid rmse 0.154744
fold 4, train rmse 0.162573, valid rmse 0.182765
5-fold validation: avg train rmse 0.165172, avg valid rmse 0.170123

有时候你会发现一组参数的训练误差可以达到很低,但是在k折交叉验证上的误差可能反而较高。这种现象很可能是由过拟合造成的。因此,当训练误差降低时,我们要观察k折交叉验证上的误差是否也相应降低。

下面定义预测函数。在预测之前,我们会使用完整的训练数据集来重新训练模型,并将预测结果存成提交所需要的格式。

In [16]: def train_and_pred(train_features, test_features, train_labels, test_data,
                            num_epochs, lr, weight_decay, batch_size):
             net = get_net()
             train_ls, _ = train(net, train_features, train_labels, None, None,
                                 num_epochs, lr, weight_decay, batch_size) 
             d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'rmse') 
             print('train rmse %f' % train_ls[-1])
             preds = net(test_features).asnumpy()
             test_data['SalePrice'] = pd.Series(preds.reshape(1, -1)[0])
             submission = pd.concat([test_data['Id'], test_data['SalePrice']], axis=1)
             submission.to_csv('submission.csv', index=False)

设计好模型并调好超参数之后,下一步就是对测试数据集上的房屋样本做价格预测。如果我们得到与交叉验证时差不多的训练误差,那么这个结果很可能是理想的,可以在 Kaggle 上提交结果。

In [17]: train_and_pred(train_features, test_features, train_labels, test_data,
                        num_epochs, lr, weight_decay, batch_size)

train rmse 0.162369

上述代码执行完之后会生成一个submission.csv文件。这个文件是符合 Kaggle 比赛要求的提交格式的。这时,我们可以在Kaggle上提交我们预测得出的结果,并且查看与测试数据集上真实房价(标签)的误差。具体来说有以下几个步骤:登录Kaggle网站,访问房价预测比赛网页,并点击右侧“Submit Predictions”或“Late Submission”按钮;然后,点击页面下方“Upload Submission File”图标所在的虚线框选择需要提交的预测结果文件;最后,点击页面最下方的“Make Submission”按钮就可以查看结果了,如图 3-9 所示。

图3-9 Kaggle预测房价比赛的预测结果提交页面

小结  

  • 通常需要对真实数据做预处理。  
  • 可以使用k折交叉验证来选择模型并调节超参数。

练习

(1)在 Kaggle 提交本节的预测结果。观察一下,这个结果在 Kaggle 上能拿到什么样的分数?

(2)对照k折交叉验证结果,不断修改模型(例如添加隐藏层)和调参,能提高 Kaggle 上的分数吗?

(3)如果不使用本节中对连续数值特征的标准化处理,结果会有什么变化?

(4)扫码直达讨论区,在社区交流方法和结果。你能发掘出其他更好的技巧吗?

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e49084”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


本章将介绍卷积神经网络,它是近年来深度学习能在计算机视觉领域取得突破性成果的基石。它也逐渐在被其他诸如自然语言处理、推荐系统和语音识别等领域广泛使用。我们将先描述卷积神经网络中卷积层和池化层的工作原理,并解释填充、步幅、输入通道和输出通道的含义。在掌握了这些基础知识以后,我们将探究数个具有代表性的深度卷积神经网络的设计思路。这些模型包括最早提出的AlexNet,以及后来的使用重复元素的网络(VGG)、网络中的网络(NiN)、含并行连结的网络(GoogLeNet)、残差网络(ResNet)和稠密连接网络(DenseNet)。它们中有不少在过去几年的ImageNet比赛(一个著名的计算机视觉竞赛)中大放异彩。虽然深度模型看上去只是具有很多层的神经网络,然而获得有效的深度模型并不容易。有幸的是,本章阐述的批量归一化和残差网络为训练和设计深度模型提供了两类重要思路。

扫码直达讨论区

卷积神经网络(convolutional neural network)是含有卷积层(convolutional layer)的神经网络。本章中介绍的卷积神经网络均使用最常见的二维卷积层。它有高和宽两个空间维度,常用来处理图像数据。本节中,我们将介绍简单形式的二维卷积层的工作原理。

虽然卷积层得名于卷积(convolution)运算,但我们通常在卷积层中使用更加直观的互相关(cross-correlation)运算。在二维卷积层中,一个二维输入数组和一个二维(kernel)数组通过互相关运算输出一个二维数组。我们用一个具体例子来解释二维互相关运算的含义。如图5-1所示,输入是一个高和宽均为3的二维数组。我们将该数组的形状记为3×3或(3, 3)。核数组的高和宽分别为2。该数组在卷积计算中又称卷积核过滤器(filter)。卷积核窗口(又称卷积窗口)的形状取决于卷积核的高和宽,即2×2。图5-1中的阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:0×0+1×1+3×2+4×3=19。

图5-1 二维互相关运算

在二维互相关运算中,卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。当卷积窗口滑动到某一位置时,窗口中的输入子数组与核数组按元素相乘并求和,得到输出数组中相应位置的元素。图5-1中的输出数组的高和宽分别为2,其中的4个元素由二维互相关运算得出:

下面我们将上述过程实现在corr2d函数里。它接受输入数组X与核数组K,并输出数组Y

In [1]: from mxnet import autograd, nd
        from mxnet.gluon import nn

        def corr2d(X, K): # 本函数已保存在d2lzh包中方便以后使用
            h, w = K.shape
            Y = nd.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
            for i in range(Y.shape[0]):
                for j in range(Y.shape[1]):
                    Y[i, j] = (X[i: i + h, j: j + w] * K).sum()
            return Y 

我们可以构造图5-1中的输入数组X、核数组K来验证二维互相关运算的输出。

In [2]: X = nd.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
        K = nd.array([[0, 1], [2, 3]])
        corr2d(X, K)

Out[2]:
        [[19. 25.]
         [37. 43.]]
        <NDArray 2x2 @cpu(0)>

二维卷积层将输入和卷积核做互相关运算,并加上一个标量偏差来得到输出。卷积层的模型参数包括了卷积核和标量偏差。在训练模型的时候,通常我们先对卷积核随机初始化,然后不断迭代卷积核和偏差。

下面基于corr2d函数来实现一个自定义的二维卷积层。在构造函数__init__里,我们声明weightbias这两个模型参数。前向计算函数forward则是直接调用corr2d函数再加上偏差。

In [3]: class Conv2D(nn.Block):
            def __init__(self, kernel_size, **kwargs):
                super(Conv2D, self).__init__(**kwargs)
                self.weight = self.params.get('weight', shape=kernel_size)
                self.bias = self.params.get('bias', shape=(1,))

            def forward(self, x):
                return corr2d(x, self.weight.data()) + self.bias.data()

卷积窗口形状为p×q的卷积层称为p×q卷积层。同样,p×q卷积或p×q卷积核说明卷积核的高和宽分别为pq

下面我们来看一个卷积层的简单应用——检测图像中物体的边缘,即找到像素变化的位置。首先我们构造一张6×8的图像(即高和宽分别为6像素和8像素的图像)。它中间4列为黑(0),其余为白(1)。

In [4]: X = nd.ones((6, 8))
        X[:, 2:6] = 0
        X

Out[4]:
        [[1. 1. 0. 0. 0. 0. 1. 1.]
         [1. 1. 0. 0. 0. 0. 1. 1.]
         [1. 1. 0. 0. 0. 0. 1. 1.]
         [1. 1. 0. 0. 0. 0. 1. 1.]
         [1. 1. 0. 0. 0. 0. 1. 1.]
         [1. 1. 0. 0. 0. 0. 1. 1.]]
        <NDArray 6x8 @cpu(0)>

然后我们构造一个高和宽分别为1和2的卷积核K。当它与输入做互相关运算时,如果横向相邻元素相同,输出为0;否则输出为非0。

In [5]: K = nd.array([[1, -1]])

下面将输入X和我们设计的卷积核K做互相关运算。可以看出,我们将从白到黑的边缘和从黑到白的边缘分别检测成了1和-1。其余部分的输出全是0。

In [6]: Y = corr2d(X, K)
        Y

Out[6]:
        [[ 0.  1.  0.  0.  0.  -1.  0.]
         [ 0.  1.  0.  0.  0.  -1.  0.]
         [ 0.  1.  0.  0.  0.  -1.  0.]
         [ 0.  1.  0.  0.  0.  -1.  0.]
         [ 0.  1.  0.  0.  0.  -1.  0.]
         [ 0.  1.  0.  0.  0.  -1.  0.]]
        <NDArray 6x7 @cpu(0)>

由此,我们可以看出,卷积层可通过重复使用卷积核有效地表征局部空间。

最后我们来看一个例子,它使用物体边缘检测中的输入数据X和输出数据Y来学习我们构造的核数组K。我们首先构造一个卷积层,将其卷积核初始化成随机数组。接下来在每一次迭代中,我们使用平方误差来比较Y和卷积层的输出,然后计算梯度来更新权重。简单起见,这里的卷积层忽略了偏差。

虽然我们之前构造了Conv2D类,但由于corr2d使用了对单个元素赋值([i, j]=)的操作因而无法自动求梯度。下面我们使用Gluon提供的Conv2D类来实现这个例子。

In [7]: # 构造一个输出通道数为1(将在5.3节介绍通道), 核数组形状是(1, 2)的二维卷积层
        conv2d = nn.Conv2D(1, kernel_size=(1, 2)) 
        conv2d.initialize()

        # 二维卷积层使用4维输入输出, 格式为(样本, 通道, 高, 宽), 这里批量大小(批量中的样本数)和通
        # 道数均为1
        X = X.reshape((1, 1, 6, 8))
        Y = Y.reshape((1, 1, 6, 7))

        for i in range(10):
            with autograd.record(): 
                Y_hat = conv2d(X)
                l = (Y_hat - Y) ** 2
            l.backward()
            # 简单起见, 这里忽略了偏差
            conv2d.weight.data()[:] -= 3e-2 * conv2d.weight.grad()
            if (i + 1) % 2 == 0:
                print('batch %d, loss %.3f' % (i + 1, l.sum().asscalar()))

batch 2, loss 4.949
batch 4, loss 0.831
batch 6, loss 0.140
batch 8, loss 0.024
batch 10, loss 0.004 

可以看到,10次迭代后误差已经降到了一个比较小的值。现在来看一下学习到的核数组。

In [8]: conv2d.weight.data().reshape((1, 2))

Out[8]:
        [[ 0.9895    -0.9873705]]
        <NDArray 1x2 @cpu(0)>

可以看到,学习到的核数组与我们之前定义的核数组K较接近。

实际上,卷积运算与互相关运算类似。为了得到卷积运算的输出,我们只需将核数组左右翻转并上下翻转,再与输入数组做互相关运算。可见,卷积运算和互相关运算虽然类似,但如果它们使用相同的核数组,对于同一个输入,输出往往并不相同。

那么,你也许会好奇卷积层为何能使用互相关运算替代卷积运算。其实,在深度学习中核数组都是学习出来的:卷积层无论使用互相关运算或卷积运算都不影响模型预测时的输出。为了解释这一点,假设卷积层使用互相关运算学习出图5-1中的核数组。设其他条件不变,使用卷积运算学习出的核数组即图5-1中的核数组按上下、左右翻转。也就是说,图5-1中的输入与学习出的已翻转的核数组再做卷积运算时,依然得到图5-1中的输出。为了与大多数深度学习文献一致,如无特别说明,本书中提到的卷积运算均指互相关运算。

二维卷积层输出的二维数组可以看作输入在空间维度(宽和高)上某一级的表征,也叫特征图(feature map)。影响元素x的前向计算的所有可能输入区域(可能大于输入的实际尺寸)叫作x感受野(receptive field)。以图5-1为例,输入中阴影部分的4个元素是输出中阴影部分元素的感受野。我们将图5-1中形状为2×2的输出记为Y,并考虑一个更深的卷积神经网络:将Y与另一个形状为2×2的核数组做互相关运算,输出单个元素z。那么,zY上的感受野包括Y的全部4个元素,在输入上的感受野包括其中全部9个元素。可见,我们可以通过更深的卷积神经网络使特征图中单个元素的感受野变得更加广阔,从而捕捉输入上更大尺寸的特征。

我们常使用“元素”一词来描述数组或矩阵中的成员。在神经网络的术语中,这些元素也可称为“单元”。当含义明确时,本书不对这两个术语做严格区分。

小结  

  • 二维卷积层的核心计算是二维互相关运算。在最简单的形式下,它对二维输入数据和卷积核做互相关运算然后加上偏差。  
  • 可以设计卷积核来检测图像中的边缘。  
  • 可以通过数据来学习卷积核。

练习  

(1)构造一个输入图像X,令它有水平方向的边缘。如何设计卷积核K来检测图像中水平边缘?如果是对角方向的边缘呢?

(2)试着对我们自己构造的Conv2D类进行自动求梯度,会有什么样的错误信息?在该类的forward函数里,将corr2d函数替换成nd.Convolution类使得自动求梯度变得可行。

(3)如何通过变化输入和核数组将互相关运算表示成一个矩阵乘法?

(4)如何构造一个全连接层来进行物体边缘检测?

扫码直达讨论区

在5.1节的例子里,我们使用高和宽为3的输入与高和宽为2的卷积核得到高和宽为2的输出。一般来说,假设输入形状是,卷积核窗口形状是,那么输出形状将会是

所以卷积层的输出形状由输入形状和卷积核窗口形状决定。本节我们将介绍卷积层的两个超参数,即填充和步幅。它们可以对给定形状的输入和卷积核改变输出形状。

填充(padding)是指在输入高和宽的两侧填充元素(通常是0元素)。图5-2里我们在原输入高和宽的两侧分别添加了值为0的元素,使得输入高和宽从3变成了5,并导致输出高和宽由2增加到4。图5-2中的阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:

图5-2 在输入的高和宽两侧分别填充了0元素的二维互相关计算

一般来说,如果在高的两侧一共填充ph行,在宽的两侧一共填充pw列,那么输出形状将会是

也就是说,输出的高和宽会分别增加phpw

在很多情况下,我们会设置来使输入和输出具有相同的高和宽。这样会方便在构造网络时推测每个层的输出形状。假设这里kh是奇数,我们会在高的两侧分别填充 行。如果kh是偶数,一种可能是在输入的顶端一侧填充行,而在底端一侧填充 行。在宽的两侧填充同理。

卷积神经网络经常使用奇数高和宽的卷积核,如1、3、5和7,所以两端上的填充个数相等。对任意的二维数组X,设它的第i行第j列的元素为X[i, j]。当两端上的填充个数相等,并使输入和输出具有相同的高和宽时,我们就知道输出Y[i, j]是由输入以X[i, j]为中心的窗口同卷积核进行互相关计算得到的。

下面的例子里我们创建一个高和宽为3的二维卷积层,然后设输入高和宽两侧的填充数分别为1。给定一个高和宽为8的输入,我们发现输出的高和宽也是8。

In [1]: from mxnet import nd
        from mxnet.gluon import nn

        # 定义一个函数来计算卷积层。它初始化卷积层权重, 并对输入和输出做相应的升维和降维
        def comp_conv2d(conv2d, X): 
            conv2d.initialize()
            # (1, 1)代表批量大小和通道数(5.3节将介绍)均为1
            X = X.reshape((1, 1) + X.shape) 
            Y = conv2d(X)
            return Y.reshape(Y.shape[2:]) # 排除不关心的前两维:批量和通道

        # 注意这里是两侧分别填充1行或列, 所以在两侧一共填充2行或列
        conv2d = nn.Conv2D(1, kernel_size=3, padding=1) 
        X = nd.random.uniform(shape=(8, 8)) 
        comp_conv2d(conv2d, X).shape
Out[1]: (8, 8)

当卷积核的高和宽不同时,我们也可以通过设置高和宽上不同的填充数使输出和输入具有相同的高和宽。

In [2]: # 使用高为5、宽为3的卷积核。在高和宽两侧的填充数分别为2和1
        conv2d = nn.Conv2D(1, kernel_size=(5, 3), padding=(2, 1)) 
        comp_conv2d(conv2d, X).shape

Out[2]: (8, 8)

在5.1节里我们介绍了二维互相关运算。卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。我们将每次滑动的行数和列数称为步幅(stride)。

目前我们看到的例子里,在高和宽两个方向上步幅均为 1。我们也可以使用更大步幅。图 5-3 展示了在高上步幅为 3、在宽上步幅为 2 的二维互相关运算。可以看到,输出第一列第二个元素时,卷积窗口向下滑动了3行,而在输出第一行第二个元素时卷积窗口向右滑动了2列。当卷积窗口在输入上再向右滑动2列时,由于输入元素无法填满窗口,无结果输出。图5-3中的阴影部分为输出元素及其计算所使用的输入和核数组元素:

图5-3 高和宽上步幅分别为3和2的二维互相关运算

一般来说,当高上步幅为,宽上步幅为时,输出形状为

如果设置 ,那么输出形状将简化为。更进一步,如果输入的高和宽能分别被高和宽上的步幅整除,那么输出形状将是

下面我们令高和宽上的步幅均为2,从而使输入的高和宽减半。

In [3]: conv2d = nn.Conv2D(1, kernel_size=3, padding=1, strides=2) 
        comp_conv2d(conv2d, X).shape

Out[3]: (4, 4)

接下来是一个稍微复杂点儿的例子。

In [4]: conv2d = nn.Conv2D(1, kernel_size=(3, 5), padding=(0, 1), strides=(3, 4)) 
        comp_conv2d(conv2d, X).shape

Out[4]: (2, 2)

为了表述简洁,当输入的高和宽两侧的填充数分别为时,我们称填充为。特别地,当时,填充为p。当在高和宽上的步幅分别为时,我们称步幅为。特别地,当 时,步幅为s。在默认情况下,填充为0,步幅为1。

小结  

  • 填充可以增加输出的高和宽。这常用来使输出与输入具有相同的高和宽。  
  • 步幅可以减小输出的高和宽,例如输出的高和宽仅为输入的高和宽的1/nn为大于1的整数)。

练习  

(1)对本节最后一个例子通过形状计算公式来计算输出形状,看看是否和实验结果一致。

(2)在本节实验中,试一试其他的填充和步幅组合。

扫码直达讨论区

5.1节和5.2节里我们用到的输入和输出都是二维数组,但真实数据的维度经常更高。例如,彩色图像在高和宽2个维度外还有RGB(红、绿、蓝)3个颜色通道。假设彩色图像的高和宽分别是hw(像素),那么它可以表示为一个的多维数组。我们将大小为3的这一维称为通道(channel)维。本节我们将介绍含多个输入通道或多个输出通道的卷积核。

当输入数据含多个通道时,我们需要构造一个输入通道数与输入数据的通道数相同的卷积核,从而能够与含多通道的输入数据做互相关运算。假设输入数据的通道数为ci,那么卷积核的输入通道数同样为ci。设卷积核窗口形状为。当时,我们知道卷积核只包含一个形状为的二维数组。当时,我们将会为每个输入通道各分配一个形状为的核数组。把这ci个数组在输入通道维上连结,即得到一个形状为的卷积核。由于输入和卷积核各有ci个通道,我们可以在各个通道上对输入的二维数组和卷积核的二维核数组做互相关运算,再将这ci个互相关运算的二维输出按通道相加,得到一个二维数组。这就是含多个通道的输入数据与多输入通道的卷积核做二维互相关运算的输出。

图5-4展示了含2个输入通道的二维互相关计算的例子。在每个通道上,二维输入数组与二维核数组做互相关运算,再按通道相加即得到输出。图5-4中阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:

图5-4 含2个输入通道的互相关计算

接下来我们实现含多个输入通道的互相关运算。我们只需要对每个通道做互相关运算,然后通过add_n函数来进行累加。

In [1]: import d2lzh as d2l
        from mxnet import nd

        def corr2d_multi_in(X, K):
            # 首先沿着X和K的第0维(通道维)遍历。然后使用*将结果列表变成add_n函数的位置参数
            # (positional argument)来进行相加
            return nd.add_n(*[d2l.corr2d(x, k) for x, k in zip(X, K)])

我们可以构造图 5-4 中的输入数组X、核数组K来验证互相关运算的输出。

In [2]: X = nd.array([[[0, 1, 2], [3, 4, 5], [6, 7, 8]],
                      [[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
        K = nd.array([[[0, 1], [2, 3]], [[1, 2], [3, 4]]])

        corr2d_multi_in(X, K)

Out[2]:
        [[ 56. 72.]
         [104. 120.]]
        <NDArray 2x2 @cpu(0)>

当输入通道有多个时,因为我们对各个通道的结果做了累加,所以不论输入通道数是多少,输出通道数总是为1。设卷积核输入通道数和输出通道数分别为,高和宽分别为。如果希望得到含多个通道的输出,我们可以为每个输出通道分别创建形状为的核数组。将它们在输出通道维上连结,卷积核的形状即。在做互相关运算时,每个输出通道上的结果由卷积核在该输出通道上的核数组与整个输入数组计算而来。

下面我们实现一个互相关运算函数来计算多个通道的输出。

In [3]: def corr2d_multi_in_out(X, K):
            # 对K的第0维遍历, 每次同输入X做互相关计算。所有结果使用stack函数合并在一起
            return nd.stack(*[corr2d_multi_in(X, k) for k in K])

我们将核数组KK1K中每个元素加一)和K2连结在一起来构造一个输出通道数为3的卷积核。

In [4]: K = nd.stack(K, K + 1, K + 2)
        K.shape

Out[4]: (3, 2, 2, 2)

下面我们对输入数组X与核数组K做互相关运算。此时的输出含有3个通道,其中第一个通道的结果与之前输入数组X与多输入通道、单输出通道核的计算结果一致。

In [5]: corr2d_multi_in_out(X, K)

Out[5]:
        [[[ 56. 72.]
          [104. 120.]]

         [[ 76. 100.]
          [148. 172.]]

         [[ 96. 128.]
          [192. 224.]]]
        <NDArray 3x2x2 @cpu(0)>

最后我们讨论卷积窗口形状为1×1 的多通道卷积层。我们通常称之为 1× 卷积层,并将其中的卷积运算称为1×1 卷积。因为使用了最小窗口,1×1卷积失去了卷积层可以识别高和宽维度上相邻元素构成的模式的功能。实际上,1×1卷积的主要计算发生在通道维上。图5-5展示了使用输入通道数为3、输出通道数为2的1×1卷积核的互相关计算。值得注意的是,输入和输出具有相同的高和宽。输出中的每个元素来自输入中在高和宽上相同位置的元素在不同通道之间的按权重累加。假设我们将通道维当作特征维,将高和宽维度上的元素当成数据样本,那么1×1 卷积层的作用与全连接层等价。

图5-5 使用输入通道数为3、输出通道数为2的1 × 1卷积核的互相关计算。输入和输出具有相同的高和宽

下面我们使用全连接层中的矩阵乘法来实现1×1卷积。这里需要在矩阵乘法运算前后对数据形状做一些调整。

In [6]: def corr2d_multi_in_out_1x1(X, K): 
            c_i, h, w = X.shape
            c_o = K.shape[0]
            X = X.reshape((c_i, h * w)) 
            K = K.reshape((c_o, c_i))
            Y = nd.dot(K, X) # 全连接层的矩阵乘法
            return Y.reshape((c_o, h, w))

经验证,做1×1卷积时,以上函数与之前实现的互相关运算函数corr2d_multi_in_out等价。

In [7]: X = nd.random.uniform(shape=(3, 3, 3))
        K = nd.random.uniform(shape=(2, 3, 1, 1))
        Y1 = corr2d_multi_in_out_1x1(X, K) 
        Y2 = corr2d_multi_in_out(X, K)

        (Y1 - Y2).norm().asscalar() < 1e-6

Out[7]: True

在之后的模型里我们将会看到1×1卷积层被当作保持高和宽维度形状不变的全连接层使用。于是,我们可以通过调整网络层之间的通道数来控制模型复杂度。

小结  

  • 使用多通道可以拓展卷积层的模型参数。  
  • 假设将通道维当作特征维,将高和宽维度上的元素当成数据样本,那么1×1卷积层的作用与全连接层等价。
  • 1×1卷积层通常用来调整网络层之间的通道数,并控制模型复杂度。

练习  

(1)假设输入形状为,且使用形状为、填充为、步幅为的卷积核。那么这个卷积层的前向计算分别需要多少次乘法和加法?

(2)翻倍输入通道数和输出通道数会增加多少倍计算?翻倍填充呢?

(3)如果卷积核的高和宽,能减少多少计算?

(4)本节最后一个例子中的变量Y1Y2完全一致吗?原因是什么?

(5)当卷积窗口不为1×1时,如何用矩阵乘法实现卷积计算?

扫码直达讨论区

回忆一下,在5.1节里介绍的图像物体边缘检测应用中,我们构造卷积核从而精确地找到了像素变化的位置。设任意二维数组Xij列的元素为X[i, j]。如果我们构造的卷积核输出Y[i, j]=1,那么说明输入中X[i, j]X[i, j+1]数值不一样。这可能意味着物体边缘通过这两个元素之间。但实际图像里,我们感兴趣的物体不会总出现在固定位置:即使我们连续拍摄同一个物体也极有可能出现像素位置上的偏移。这会导致同一个边缘对应的输出可能出现在卷积输出Y中的不同位置,进而对后面的模式识别造成不便。

在本节中我们介绍池化(pooling),它的提出是为了缓解卷积层对位置的过度敏感性。

同卷积层一样,池化层每次对输入数据的一个固定形状窗口(又称池化窗口)中的元素计算输出。不同于卷积层里计算输入和核的互相关性,池化层直接计算池化窗口内元素的最大值或者平均值。该运算也分别叫作最大池化平均池化。在二维最大池化中,池化窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。当池化窗口滑动到某一位置时,窗口中的输入子数组的最大值即输出数组中相应位置的元素。

图5-6 池化窗口形状为2 × 2的最大池化

图5-6展示了池化窗口形状为2×2的最大池化,阴影部分为第一个输出元素及其计算所使用的输入元素。输出数组的高和宽分别为2,其中的4个元素由取最大值运算max得出:

二维平均池化的工作原理与二维最大池化类似,但将最大运算符替换成平均运算符。池化窗口形状为的池化层称为池化层,其中的池化运算叫作池化。

让我们再次回到本节开始提到的物体边缘检测的例子。现在我们将卷积层的输出作为2×2最大池化的输入。设该卷积层输入是X、池化层输出为Y。无论是X[i, j]X[i, j+1]值不同,还是X[i, j+1]X[i, j+2]不同,池化层输出均有Y[i, j]=1。也就是说,使用2×2最大池化层时,只要卷积层识别的模式在高和宽上移动不超过一个元素,我们依然可以将它检测出来。

下面把池化层的前向计算实现在pool2d函数里。它与5.1节里corr2d函数非常类似,唯一的区别在计算输出Y上。

In [1]: from mxnet import nd
        from mxnet.gluon import nn
        def pool2d(X, pool_size, mode='max'): 
            p_h, p_w = pool_size
            Y = nd.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
            for i in range(Y.shape[0]):
                for j in range(Y.shape[1]):
                    if mode == 'max':
                        Y[i, j] = X[i: i + p_h, j: j + p_w].max()
                    elif mode == 'avg':
                        Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
            return Y 

我们可以构造图5-6中的输入数组X来验证二维最大池化层的输出。

In [2]: X = nd.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
        pool2d(X, (2, 2))
Out[2]:
        [[4. 5.]
         [7. 8.]]
        <NDArray 2x2 @cpu(0)>

同时我们实验一下平均池化层。

In [3]: pool2d(X, (2, 2), 'avg')

Out[3]:
        [[2. 3.]
         [5. 6.]]
        <NDArray 2x2 @cpu(0)>

同卷积层一样,池化层也可以在输入的高和宽两侧的填充并调整窗口的移动步幅来改变输出形状。池化层填充和步幅与卷积层填充和步幅的工作机制一样。我们将通过nn模块里的二维最大池化层MaxPool2D来演示池化层填充和步幅的工作机制。我们先构造一个形状为(1, 1, 4, 4)的输入数据,前两个维度分别是批量和通道。

In [4]: X = nd.arange(16).reshape((1, 1, 4, 4)) 
        X

Out[4]:
        [[[[ 0.  1.  2.  3.]
           [ 4.  5.  6.  7.]
           [ 8.  9. 10. 11.]
           [12. 13. 14. 15.]]]]
        <NDArray 1x1x4x4 @cpu(0)>

默认情况下,MaxPool2D实例里步幅和池化窗口形状相同。下面使用形状为(3, 3)的池化窗口,默认获得形状为(3, 3)的步幅。

In [5]: pool2d = nn.MaxPool2D(3)
        pool2d(X) # 因为池化层没有模型参数, 所以不需要调用参数初始化函数

Out[5]:
        [[[[10.]]]]
        <NDArray 1x1x1x1 @cpu(0)>

我们可以手动指定步幅和填充。

In [6]: pool2d = nn.MaxPool2D(3, padding=1, strides=2) 
        pool2d(X)

Out[6]:
        [[[[ 5.  7.]
           [13. 15.]]]]
        <NDArray 1x1x2x2 @cpu(0)>

当然,我们也可以指定非正方形的池化窗口,并分别指定高和宽上的填充和步幅。

In [7]: pool2d = nn.MaxPool2D((2, 3), padding=(1, 2), strides=(2, 3)) 
        pool2d(X)

Out[7]:
        [[[[ 0.  3.]
           [ 8. 11.]
           [12. 15.]]]]
        <NDArray 1x1x3x2 @cpu(0)>

在处理多通道输入数据时,池化层对每个输入通道分别池化,而不是像卷积层那样将各通道的输入按通道相加。这意味着池化层的输出通道数与输入通道数相等。下面我们将数组XX+1在通道维上连结来构造通道数为2的输入。

In [8]: X = nd.concat(X, X + 1, dim=1) 
        X

Out[8]:
        [[[[ 0.  1.  2.  3.]
           [ 4.  5.  6.  7.]
           [ 8.  9. 10. 11.]
           [12. 13. 14. 15.]]

          [[ 1.  2.  3.  4.]
           [ 5.  6.  7.  8.]
           [ 9. 10. 11. 12.]
           [13. 14. 15. 16.]]]]
        <NDArray 1x2x4x4 @cpu(0)>

池化后,我们发现输出通道数仍然是 2。

In [9]: pool2d = nn.MaxPool2D(3, padding=1, strides=2) 
        pool2d(X)

Out[9]:
        [[[[ 5.  7.]
           [13. 15.]]

          [[ 6.  8.]
           [14. 16.]]]]
        <NDArray 1x2x2x2 @cpu(0)>

小结  

  • 最大池化和平均池化分别取池化窗口中输入元素的最大值和平均值作为输出。  
  • 池化层的一个主要作用是缓解卷积层对位置的过度敏感性。  
  • 可以指定池化层的填充和步幅。  
  • 池化层的输出通道数与输入通道数相同。

练习  

(1)分析池化层的计算复杂度。假设输入形状为,我们使用形状为的池化窗口,而且使用填充和步幅。这个池化层的前向计算复杂度有多大?

(2)想一想,最大池化层和平均池化层在作用上可能有哪些区别?

(3)你觉得最小池化层这个想法有没有意义?

扫码直达讨论区

在3.9节里我们构造了一个含单隐藏层的多层感知机模型来对Fashion-MNIST数据集中的图像进行分类。每张图像高和宽均是28像素。我们将图像中的像素逐行展开,得到长度为784的向量,并输入进全连接层中。然而,这种分类方法有一定的局限性。

(1)图像在同一列邻近的像素在这个向量中可能相距较远。它们构成的模式可能难以被模型识别。

(2)对于大尺寸的输入图像,使用全连接层容易造成模型过大。假设输入是高和宽均为1 000像素的彩色照片(含3个通道)。即使全连接层输出个数仍是256,该层权重参数的形状是3 000 000×256:它占用了大约3 GB的内存或显存。这带来过复杂的模型和过高的存储开销。

卷积层尝试解决这两个问题。一方面,卷积层保留输入形状,使图像的像素在高和宽两个方向上的相关性均可能被有效识别;另一方面,卷积层通过滑动窗口将同一卷积核与不同位置的输入重复计算,从而避免参数尺寸过大。

卷积神经网络就是含卷积层的网络。本节里我们将介绍一个早期用来识别手写数字图像的卷积神经网络——LeNet [31]。这个名字来源于LeNet论文的第一作者Yann LeCun。LeNet展示了通过梯度下降训练卷积神经网络可以达到手写数字识别在当时最先进的结果。这个奠基性的工作第一次将卷积神经网络推上舞台,为世人所知。

LeNet分为卷积层块和全连接层块两个部分。下面我们分别介绍这两个模块。

卷积层块里的基本单位是卷积层后接最大池化层:卷积层用来识别图像里的空间模式,如线条和物体局部,之后的最大池化层则用来降低卷积层对位置的敏感性。卷积层块由两个这样的基本单位重复堆叠构成。在卷积层块中,每个卷积层都使用5×5的窗口,并在输出上使用 sigmoid 激活函数。第一个卷积层输出通道数为6,第二个卷积层输出通道数则增加到16。这是因为第二个卷积层比第一个卷积层的输入的高和宽要小,所以增加输出通道使两个卷积层的参数尺寸类似。卷积层块的两个最大池化层的窗口形状均为2×2,且步幅为2。由于池化窗口与步幅形状相同,池化窗口在输入上每次滑动所覆盖的区域互不重叠。

卷积层块的输出形状为( 批量大小 , 通道 , 高 , 宽 )。当卷积层块的输出传入全连接层块时,全连接层块会将小批量中每个样本变平(flatten)。也就是说,全连接层的输入形状将变成二维,其中第一维是小批量中的样本,第二维是每个样本变平后的向量表示,且向量长度为通道、高和宽的乘积。全连接层块含3个全连接层。它们的输出个数分别是 120、84 和 10,其中 10 为输出的类别个数。

下面我们通过Sequential类来实现 LeNet 模型。

In [1]: import d2lzh as d2l
        import mxnet as mx
        from mxnet import autograd, gluon, init, nd 
        from mxnet.gluon import loss as gloss, nn 
        import time

        net = nn.Sequential()
        net.add(nn.Conv2D(channels=6, kernel_size=5, activation='sigmoid'), 
                nn.MaxPool2D(pool_size=2, strides=2),
                nn.Conv2D(channels=16, kernel_size=5, activation='sigmoid'), 
                nn.MaxPool2D(pool_size=2, strides=2),
                # Dense会默认将(批量大小, 通道, 高, 宽)形状的输入转换成
                # (批量大小, 通道 * 高 * 宽)形状的输入 
                nn.Dense(120, activation='sigmoid'), 
                nn.Dense(84, activation='sigmoid'), 
                nn.Dense(10))

接下来我们构造一个高和宽均为 28 的单通道数据样本,并逐层进行前向计算来查看每个层的输出形状。

In [2]: X = nd.random.uniform(shape=(1, 1, 28, 28)) 
        net.initialize()
        for layer in net: 
            X = layer(X)
            print(layer.name, 'output shape:\t', X.shape)

conv0 output shape:      (1, 6, 24, 24)
pool0 output shape:      (1, 6, 12, 12)
conv1 output shape:      (1, 16, 8, 8)
pool1 output shape:      (1, 16, 4, 4)
dense0 output shape:     (1, 120)
dense1 output shape:     (1, 84)
dense2 output shape:     (1, 10)

可以看到,在卷积层块中输入的高和宽在逐层减小。卷积层由于使用高和宽均为 5 的卷积核,从而将高和宽分别减小 4,而池化层则将高和宽减半,但通道数则从 1 增加到16。全连接层则逐层减少输出个数,直到变成图像的类别数 10。

下面我们来实验 LeNet 模型。实验中,我们仍然使用 Fashion-MNIST 作为训练数据集。

In [3]: batch_size = 256
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

因为卷积神经网络计算比多层感知机要复杂,建议使用GPU来加速计算。我们尝试在gpu(0)上创建NDArray,如果成功则使用gpu(0),否则仍然使用 CPU。

In [4]: def try_gpu(): # 本函数已保存在d2lzh包中方便以后使用
            try:
                ctx = mx.gpu()
                _ = nd.zeros((1,), ctx=ctx)
            except mx.base.MXNetError: 
                ctx = mx.cpu()
            return ctx

        ctx = try_gpu() 
        ctx
Out[4]: gpu(0)

相应地,我们对3.6节中描述的evaluate_accuracy函数略作修改。由于数据刚开始存在CPU使用的内存上,当ctx变量代表GPU及相应的显存时,我们通过4.6节中介绍的as_in_context函数将数据复制到显存上,例如gpu(0)

In [5]: # 本函数已保存在d2lzh包中方便以后使用。该函数将被逐步改进:它的完整实现将在9.1节中描述
        def evaluate_accuracy(data_iter, net, ctx): 
            acc_sum, n = nd.array([0], ctx=ctx), 0 
            for X, y in data_iter:
                # 如果ctx代表GPU及相应的显存, 将数据复制到显存上
                X, y = X.as_in_context(ctx), y.as_in_context(ctx).astype('f loat32') 
                acc_sum += (net(X).argmax(axis=1) == y).sum()
                n += y.size
            return acc_sum.asscalar() / n

我们同样对3.6节中定义的train_ch3函数略作修改,确保计算使用的数据和模型同在内存或显存上。

In [6]: # 本函数已保存在d2lzh包中方便以后使用
        def train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, 
                      num_epochs):
            print('training on', ctx)
            loss = gloss.SoftmaxCrossEntropyLoss()
            for epoch in range(num_epochs):
                train_l_sum, train_acc_sum, n, start = 0.0, 0.0, 0, time.time()
                for X, y in train_iter:
                    X, y = X.as_in_context(ctx), y.as_in_context(ctx)
                    with autograd.record(): 
                        y_hat = net(X)
                        l = loss(y_hat, y).sum() 
                    l.backward() 
                    trainer.step(batch_size)
                    y = y.astype('f loat32') 
                    train_l_sum += l.asscalar()
                    train_acc_sum += (y_hat.argmax(axis=1) == y).sum().asscalar() 
                    n += y.size
                test_acc = evaluate_accuracy(test_iter, net, ctx)
                print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, ' 
                      'time %.1f sec'
                      % (epoch + 1, train_l_sum / n, train_acc_sum / n, test_acc, 
                         time.time() - start))

我们重新将模型参数初始化到设备变量ctx之上,并使用 Xavier 随机初始化。损失函数和训练算法则依然使用交叉熵损失函数和小批量随机梯度下降。

In [7]: lr, num_epochs = 0.9, 5
        net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs)

training on gpu(0)
epoch 1, loss 2.3205, train acc 0.100, test acc 0.174, time 1.9 sec
epoch 2, loss 2.0349, train acc 0.215, test acc 0.505, time 1.7 sec
epoch 3, loss 0.9928, train acc 0.605, test acc 0.689, time 1.7 sec
epoch 4, loss 0.7733, train acc 0.700, test acc 0.731, time 1.7 sec
epoch 5, loss 0.6794, train acc 0.731, test acc 0.755, time 1.7 sec

小结  

  • 卷积神经网络就是含卷积层的网络。  
  • LeNet 交替使用卷积层和最大池化层后接全连接层来进行图像分类。

练习  

尝试基于LeNet构造更复杂的网络来提高分类准确率。例如,调整卷积窗口大小、输出通道数、激活函数和全连接层输出个数。在优化方面,可以尝试使用不同的学习率、初始化方法以及增加迭代周期。

扫码直达讨论区

在 LeNet 提出后的将近20年里,神经网络一度被其他机器学习方法超越,如支持向量机。虽然 LeNet 可以在早期的小数据集上取得好的成绩,但是在更大的真实数据集上的表现并不尽如人意。一方面,神经网络计算复杂。虽然20世纪90年代也有过一些针对神经网络的加速硬件,但并没有像之后 GPU 那样大量普及。因此,训练一个多通道、多层和有大量参数的卷积神经网络在当年很难完成。另一方面,当年研究者还没有大量深入研究参数初始化和非凸优化算法等诸多领域,导致复杂的神经网络的训练通常较困难。

我们在上一节看到,神经网络可以直接基于图像的原始像素进行分类。这种称为端到端(end-to-end)的方法节省了很多中间步骤。然而,在很长一段时间里更流行的是研究者通过勤劳与智慧设计并生成的手工特征。这类图像分类研究的主要流程是:

(1)获取图像数据集;

(2)使用已有的特征提取函数生成图像的特征;

(3)使用机器学习模型对图像的特征分类。

当时认为的机器学习部分仅限最后这一步。如果那时候跟机器学习研究者交谈,他们会认为机器学习既重要又优美。优雅的定理证明了许多分类器的性质。机器学习领域生机勃勃、严谨而且极其有用。然而,如果跟计算机视觉研究者交谈,则是另外一幅景象。他们会告诉你图像识别里“不可告人”的现实是:计算机视觉流程中真正重要的是数据和特征。也就是说,使用较干净的数据集和较有效的特征甚至比机器学习模型的选择对图像分类结果的影响更大。

既然特征如此重要,它该如何表示呢?

我们已经提到,在相当长的时间里,特征都是基于各式各样手工设计的函数从数据中提取的。事实上,不少研究者通过提出新的特征提取函数不断改进图像分类结果。这一度为计算机视觉的发展做出了重要贡献。

然而,另一些研究者则持异议。他们认为特征本身也应该由学习得来。他们还相信,为了表征足够复杂的输入,特征本身应该分级表示。持这一想法的研究者相信,多层神经网络可能可以学得数据的多级表征,并逐级表示越来越抽象的概念或模式。以图像分类为例,并回忆5.1节中物体边缘检测的例子。在多层神经网络中,图像的第一级的表示可以是在特定的位置和角度是否出现边缘;而第二级的表示说不定能够将这些边缘组合出有趣的模式,如花纹;在第三级的表示中,也许上一级的花纹能进一步汇合成对应物体特定部位的模式。这样逐级表示下去,最终,模型能够较容易根据最后一级的表示完成分类任务。需要强调的是,输入的逐级表示由多层模型中的参数决定,而这些参数都是学出来的。

尽管一直有一群执着的研究者不断钻研,试图学习视觉数据的逐级表征,然而很长一段时间里这些野心都未能实现。这其中有诸多因素值得我们一一分析。

1.缺失要素一:数据

包含许多特征的深度模型需要大量的有标签的数据才能表现得比其他经典方法更好。限于早期计算机有限的存储和20世纪90年代有限的研究预算,大部分研究只基于小的公开数据集。例如,不少研究论文基于加州大学欧文分校(UCI)提供的若干个公开数据集,其中许多数据集只有几百至几千张图像。这一状况在2010年前后兴起的大数据浪潮中得到改善。特别是,2009年诞生的ImageNet数据集包含了1 000大类物体,每类有多达数千张不同的图像。这一规模是当时其他公开数据集无法与之相提并论的。ImageNet 数据集同时推动计算机视觉和机器学习研究进入新的阶段,使此前的传统方法不再有优势。

2.缺失要素二:硬件

深度学习对计算资源要求很高。早期的硬件计算能力有限,这使训练较复杂的神经网络变得很困难。然而,通用GPU的到来改变了这一格局。很久以来,GPU都是为图像处理和计算机游戏设计的,尤其是针对大吞吐量的矩阵和向量乘法,从而服务于基本的图形变换。值得庆幸的是,这其中的数学表达与深度网络中的卷积层的表达类似。通用GPU这个概念在2001年开始兴起,涌现出诸如OpenCL和CUDA之类的编程框架。这使得GPU也在2010年前后开始被机器学习社区使用。

2012年AlexNet横空出世。这个模型的名字来源于论文第一作者的姓名Alex Krizhevsky[30]。AlexNet 使用了 8 层卷积神经网络,并以很大的优势赢得了ImageNet 2012图像识别挑战赛。它首次证明了学习到的特征可以超越手工设计的特征,从而一举打破计算机视觉研究的前状。

AlexNet与LeNet的设计理念非常相似,但也有显著的区别。

第一,与相对较小的LeNet相比,AlexNet包含8层变换,其中有5层卷积和2层全连接隐藏层,以及1个全连接输出层。下面我们来详细描述这些层的设计。

AlexNet第一层中的卷积窗口形状是11×11。因为ImageNet中绝大多数图像的高和宽均比MNIST图像的高和宽大10倍以上,ImageNet图像的物体占用更多的像素,所以需要更大的卷积窗口来捕获物体。第二层中的卷积窗口形状减小到5×5,之后全采用3×3。此外,第一、第二和第五个卷积层之后都使用了窗口形状为3×3、步幅为2的最大池化层。而且,AlexNet使用的卷积通道数也数十倍于LeNet中的卷积通道数。

紧接着最后一个卷积层的是两个输出个数为4 096的全连接层。这两个巨大的全连接层带来将近1 GB的模型参数。由于早期显存的限制,最早的AlexNet使用双数据流的设计使一块GPU只需要处理一半模型。幸运的是,显存在过去几年得到了长足的发展,因此通常我们不再需要这样的特别设计了。

第二,AlexNet将sigmoid激活函数改成了更加简单的ReLU激活函数。一方面,ReLU激活函数的计算更简单,例如它并没有sigmoid激活函数中的求幂运算。另一方面,ReLU激活函数在不同的参数初始化方法下使模型更容易训练。这是由于当sigmoid激活函数输出极接近0或1时,这些区域的梯度几乎为0,从而造成反向传播无法继续更新部分模型参数;而ReLU激活函数在正区间的梯度恒为1。因此,若模型参数初始化不当,sigmoid函数可能在正区间得到几乎为0的梯度,从而令模型无法得到有效训练。

第三,AlexNet通过丢弃法(参见3.13节)来控制全连接层的模型复杂度。而LeNet并没有使用丢弃法。

第四,AlexNet引入了大量的图像增广,如翻转、裁剪和颜色变化,从而进一步扩大数据集来缓解过拟合。我们将在后面的9.1节详细介绍这种方法。

下面我们实现稍微简化过的AlexNet。

In [1]: import d2lzh as d2l
        from mxnet import gluon, init, nd
        from mxnet.gluon import data as gdata, nn
        import os
        import sys

        net = nn.Sequential()
        # 使用较大的11 × 11窗口来捕获物体。同时使用步幅4来较大幅度减小输出高和宽。这里使用的输出通
        # 道数比LeNet中的也要大很多
        net.add(nn.Conv2D(96, kernel_size=11, strides=4, activation='relu'), 
                nn.MaxPool2D(pool_size=3, strides=2),
                # 减小卷积窗口, 使用填充为2来使得输入与输出的高和宽一致, 且增大输出通道数
                nn.Conv2D(256, kernel_size=5, padding=2, activation='relu'), 
                nn.MaxPool2D(pool_size=3, strides=2),
                # 连续3个卷积层, 且使用更小的卷积窗口。除了最后的卷积层外, 进一步增大了输出通道数。
                # 前两个卷积层后不使用池化层来减小输入的高和宽
                nn.Conv2D(384, kernel_size=3, padding=1, activation='relu'), 
                nn.Conv2D(384, kernel_size=3, padding=1, activation='relu'), 
                nn.Conv2D(256, kernel_size=3, padding=1, activation='relu'), 
                nn.MaxPool2D(pool_size=3, strides=2),
                # 这里全连接层的输出个数比LeNet中的大数倍。使用丢弃层来缓解过拟合
                nn.Dense(4096, activation="relu"), nn.Dropout(0.5), 
                nn.Dense(4096, activation="relu"), nn.Dropout(0.5),
                # 输出层。由于这里使用Fashion-MNIST, 所以用类别数为10, 而非论文中的1000
                nn.Dense(10))

我们构造一个高和宽均为 224 的单通道数据样本来观察每一层的输出形状。

In [2]: X = nd.random.uniform(shape=(1, 1, 224, 224)) 
        net.initialize()
        for layer in net: 
            X = layer(X)
            print(layer.name, 'output shape:\t', X.shape)

conv0 output shape:     (1, 96, 54, 54)
pool0 output shape:     (1, 96, 26, 26)
conv1 output shape:     (1, 256, 26, 26)
pool1 output shape:     (1, 256, 12, 12)
conv2 output shape:     (1, 384, 12, 12)
conv3 output shape:     (1, 384, 12, 12)
conv4 output shape:     (1, 256, 12, 12)
pool2 output shape:     (1, 256, 5, 5)
dense0 output shape:    (1, 4096)
dropout0 output shape:  (1, 4096)
dense1 output shape:    (1, 4096)
dropout1 output shape:  (1, 4096)
dense2 output shape:    (1, 10)

虽然论文中 AlexNet 使用 ImageNet 数据集,但因为 ImageNet 数据集训练时间较长,我们仍用前面的 Fashion-MNIST 数据集来演示AlexNet。读取数据的时候我们额外做了一步将图像高和宽扩大到 AlexNet 使用的图像高和宽—— 224。这个可以通过Resize实例来实现。也就是说,我们在ToTensor实例前使用Resize实例,然后使用Compose实例来将这两个变换串联以方便调用。

In [3]: # 本函数已保存在d2lzh包中方便以后使用
        def load_data_fashion_mnist(batch_size, resize=None, root=os.path.join(
                '~', '.mxnet', 'datasets', 'fashion-mnist')): 
            root = os.path.expanduser(root) # 展开用户路径'~' 
            transformer = []
            if resize:
                transformer += [gdata.vision.transforms.Resize(resize)] 
            transformer += [gdata.vision.transforms.ToTensor()] 
            transformer = gdata.vision.transforms.Compose(transformer) 
            mnist_train = gdata.vision.FashionMNIST(root=root, train=True) 
            mnist_test = gdata.vision.FashionMNIST(root=root, train=False) 
            num_workers = 0 if sys.platform.startswith('win32') else 4 
            train_iter = gdata.DataLoader(
                mnist_train.transform_f irst(transformer), batch_size, shuff le=True,
                num_workers=num_workers) 
            test_iter = gdata.DataLoader(
                mnist_test.transform_f irst(transformer), batch_size, shuff le=False, 
                num_workers=num_workers)
            return train_iter, test_iter

        batch_size = 128
        # 如出现“out of memory”的报错信息, 可减小batch_size或resize
        train_iter, test_iter = load_data_fashion_mnist(batch_size, resize=224)

这时候我们可以开始训练AlexNet了。相对于5.5节的LeNet,这里的主要改动是使用了更小的学习率。

In [4]: lr, num_epochs, ctx = 0.01, 5, d2l.try_gpu() 
        net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs)

training on gpu(0)
epoch 1, loss 1.3030, train acc 0.510, test acc 0.767, time 18.5 sec
epoch 2, loss 0.6450, train acc 0.759, test acc 0.810, time 17.4 sec
epoch 3, loss 0.5298, train acc 0.803, test acc 0.831, time 17.4 sec
epoch 4, loss 0.4664, train acc 0.828, test acc 0.851, time 17.5 sec
epoch 5, loss 0.4252, train acc 0.845, test acc 0.867, time 17.3 sec

小结  

  • AlexNet 与 LeNet 结构类似,但使用了更多的卷积层和更大的参数空间来拟合大规模数据集 ImageNet。它是浅层神经网络和深度神经网络的分界线。  
  • 虽然看上去 AlexNet 的实现比 LeNet的实现也就多了几行代码而已,但这个观念上的转变和真正优秀实验结果的产生令学术界付出了很多年。

练习  

(1)尝试增加迭代周期。跟 LeNet的结果相比,AlexNet的结果有什么区别?为什么?

(2)AlexNet对Fashion-MNIST 数据集来说可能过于复杂。试着简化模型来使训练更快,同时保证准确率不明显下降。

(3)修改批量大小,观察准确率和内存或显存的变化。

扫码直达讨论区

AlexNet 在 LeNet 的基础上增加了3个卷积层。但 AlexNet 作者对它们的卷积窗口、输出通道数和构造顺序均做了大量的调整。虽然 AlexNet 指明了深度卷积神经网络可以取得出色的结果,但并没有提供简单的规则以指导后来的研究者如何设计新的网络。我们将在本章的后续几节里介绍几种不同的深度网络设计思路。

本节介绍VGG,它的名字来源于论文作者所在的实验室Visual Geometry Group [48]。VGG提出了可以通过重复使用简单的基础块来构建深度模型的思路。

VGG 块的组成规律是:连续使用数个相同的填充为1、窗口形状为3×3的卷积层后接上一个步幅为2、窗口形状为2×2的最大池化层。卷积层保持输入的高和宽不变,而池化层则对其减半。我们使用vgg_block函数来实现这个基础的VGG块,它可以指定卷积层的数量num_convs和输出通道数num_channels

In [1]: import d2lzh as d2l
        from mxnet import gluon, init, nd
        from mxnet.gluon import nn

        def vgg_block(num_convs, num_channels): 
            blk = nn.Sequential()
            for _ in range(num_convs): 
                blk.add(nn.Conv2D(num_channels, kernel_size=3,
                                  padding=1, activation='relu')) 
            blk.add(nn.MaxPool2D(pool_size=2, strides=2))
            return blk

与AlexNet和LeNet 一样,VGG 网络由卷积层模块后接全连接层模块构成。卷积层模块串联数个vgg_block,其超参数由变量conv_arch定义。该变量指定了每个 VGG 块里卷积层个数和输出通道数。全连接模块则与 AlexNet 中的一样。

现在我们构造一个VGG网络。它有 5 个卷积块,前2块使用单卷积层,而后3块使用双卷积层。第一块的输出通道是 64,之后每次对输出通道数翻倍,直到变为 512。因为这个网络使用了 8 个卷积层和 3 个全连接层,所以经常被称为 VGG-11。

In [2]: conv_arch = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))

下面我们实现 VGG-11。

In [3]: def vgg(conv_arch):
            net = nn.Sequential()
            # 卷积层部分
            for (num_convs, num_channels) in conv_arch: 
                net.add(vgg_block(num_convs, num_channels))
            # 全连接层部分
            net.add(nn.Dense(4096, activation='relu'), nn.Dropout(0.5), 
                    nn.Dense(4096, activation='relu'), nn.Dropout(0.5),
                    nn.Dense(10))
            return net

        net = vgg(conv_arch) 

下面构造一个高和宽均为 224 的单通道数据样本来观察每一层的输出形状。

In [4]: net.initialize()
        X = nd.random.uniform(shape=(1, 1, 224, 224))
        for blk in net: 
            X = blk(X)
            print(blk.name, 'output shape:\t', X.shape)

sequential1 output shape:       (1, 64, 112, 112)
sequential2 output shape:       (1, 128, 56, 56)
sequential3 output shape:       (1, 256, 28, 28)
sequential4 output shape:       (1, 512, 14, 14)
sequential5 output shape:       (1, 512, 7, 7)
dense0 output shape:     (1, 4096)
dropout0 output shape:   (1, 4096)
dense1 output shape:     (1, 4096)
dropout1 output shape:   (1, 4096)
dense2 output shape:     (1, 10)

可以看到,每次我们将输入的高和宽减半,直到最终高和宽变成 7 后传入全连接层。与此同时,输出通道数每次翻倍,直到变成 512。因为每个卷积层的窗口大小一样,所以每层的模型参数尺寸和计算复杂度与输入高、输入宽、输入通道数和输出通道数的乘积成正比。VGG 这种高和宽减半以及通道翻倍的设计使多数卷积层都有相同的模型参数尺寸和计算复杂度。

因为 VGG-11 计算上比 AlexNet 更加复杂,出于测试的目的我们构造一个通道数更小,或者说更窄的网络在 Fashion-MNIST 数据集上进行训练。

In [5]: ratio = 4
        small_conv_arch = [(pair[0], pair[1] // ratio) for pair in conv_arch] 
        net = vgg(small_conv_arch)

除了使用了稍大些的学习率,模型训练过程与5.6节的AlexNet 中的类似。

In [6]: lr, num_epochs, batch_size, ctx = 0.05, 5, 128, d2l.try_gpu() 
        net.initialize(ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 0.9239, train acc 0.665, test acc 0.853, time 38.7 sec
epoch 2, loss 0.4129, train acc 0.850, test acc 0.879, time 37.0 sec
epoch 3, loss 0.3373, train acc 0.877, test acc 0.899, time 37.0 sec
epoch 4, loss 0.2937, train acc 0.892, test acc 0.906, time 37.0 sec
epoch 5, loss 0.2640, train acc 0.903, test acc 0.912, time 37.0 sec

小结  

  • VGG-11 通过 5 个可以重复使用的卷积块来构造网络。根据每块里卷积层个数和输出通道数的不同可以定义出不同的 VGG 模型。

练习  

(1)与 AlexNet 相比,VGG 通常计算慢很多,也需要更多的内存或显存。试分析原因。

(2)尝试将 Fashion-MNIST 中图像的高和宽由 224 改为 96。这在实验中有哪些影响?

(3)参考 VGG 论文里的表 1 来构造 VGG 其他常用模型,如VGG-16和VGG-19 [48]

扫码直达讨论区

5.5节至5.7节介绍的 LeNet、AlexNet 和 VGG 在设计上的共同之处是:先以由卷积层构成的模块充分抽取空间特征,再以由全连接层构成的模块来输出分类结果。其中,AlexNet 和 VGG 对 LeNet 的改进主要在于如何对这两个模块加宽(增加通道数)和加深。本节我们介绍网络中的网络(NiN)[33]。它提出了另外一个思路,即串联多个由卷积层和“全连接”层构成的小网络来构建一个深层网络。

我们知道,卷积层的输入和输出通常是四维数组(样本,通道,高,宽),而全连接层的输入和输出则通常是二维数组(样本, 特征)。如果想在全连接层后再接上卷积层,则需要将全连接层的输出变换为四维。回忆在5.3节里介绍的1×1卷积层。它可以看成全连接层,其中空间维度(高和宽)上的每个元素相当于样本,通道相当于特征。因此,NiN使用1×1卷积层来替代全连接层,从而使空间信息能够自然传递到后面的层中去。图5-7对比了NiN同AlexNet 和 VGG 等网络在结构上的主要区别。

图5-7 左图是AlexNet 和 VGG 的网络结构局部,右图是 NiN 的网络结构局部

NiN 块是 NiN 中的基础块。它由一个卷积层加两个充当全连接层的1×1卷积层串联而成。其中第一个卷积层的超参数可以自行设置,而第二和第三个卷积层的超参数一般是固定的。

In [1]: import d2lzh as d2l
        from mxnet import gluon, init, nd
        from mxnet.gluon import nn

        def nin_block(num_channels, kernel_size, strides, padding): 
            blk = nn.Sequential()
            blk.add(nn.Conv2D(num_channels, kernel_size,
                              strides, padding, activation='relu'), 
                    nn.Conv2D(num_channels, kernel_size=1, activation='relu'), 
                    nn.Conv2D(num_channels, kernel_size=1, activation='relu'))
            return blk

NiN 是在 AlexNet 问世不久后提出的。它们的卷积层设定有类似之处。NiN 使用卷积窗口形状分别为11×11、5×5和3×3的卷积层,相应的输出通道数也与 AlexNet 中的一致。每个 NiN 块后接一个步幅为 2、窗口形状为3×3的最大池化层。

除使用NiN 块以外,NiN 还有一个设计与 AlexNet显著不同:NiN 去掉了 AlexNet 最后的3个全连接层,取而代之地,NiN 使用了输出通道数等于标签类别数的 NiN 块,然后使用全局平均池化层对每个通道中所有元素求平均并直接用于分类。这里的全局平均池化层即窗口形状等于输入空间维形状的平均池化层。NiN 的这个设计的好处是可以显著减小模型参数尺寸,从而缓解过拟合。然而,该设计有时会造成获得有效模型的训练时间的增加。

In [2]: net = nn.Sequential()
        net.add(nin_block(96, kernel_size=11, strides=4, padding=0), 
                nn.MaxPool2D(pool_size=3, strides=2),
                nin_block(256, kernel_size=5, strides=1, padding=2), 
                nn.MaxPool2D(pool_size=3, strides=2),
                nin_block(384, kernel_size=3, strides=1, padding=1), 
                nn.MaxPool2D(pool_size=3, strides=2), nn.Dropout(0.5),
                # 标签类别数是10
                nin_block(10, kernel_size=3, strides=1, padding=1),
                # 全局平均池化层将窗口形状自动设置成输入的高和宽
                nn.GlobalAvgPool2D(),
                # 将四维的输出转成二维的输出, 其形状为(批量大小, 10)
                nn.Flatten())

我们构建一个数据样本来查看每一层的输出形状。

In [3]: X = nd.random.uniform(shape=(1, 1, 224, 224)) 
        net.initialize()
        for layer in net: 
            X = layer(X)
            print(layer.name, 'output shape:\t', X.shape)

sequential1 output shape: (1, 96, 54, 54)
pool0 output shape: (1, 96, 26, 26)
sequential2 output shape: (1, 256, 26, 26)
pool1 output shape: (1, 256, 12, 12)
sequential3 output shape: (1, 384, 12, 12)
pool2 output shape: (1, 384, 5, 5)
dropout0 output shape:  (1, 384, 5, 5)
sequential4 output shape: (1, 10, 5, 5)
pool3 output shape:     (1, 10, 1, 1)
f latten0 output shape:  (1, 10)

我们依然使用 Fashion-MNIST 数据集来训练模型。NiN 的训练与 AlexNet 和 VGG 的类似,但这里使用的学习率更大。

In [4]: lr, num_epochs, batch_size, ctx = 0.1, 5, 128, d2l.try_gpu() 
        net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 2.2635, train acc 0.153, test acc 0.147, time 24.6 sec
epoch 2, loss 1.3903, train acc 0.499, test acc 0.699, time 23.5 sec
epoch 3, loss 0.8132, train acc 0.707, test acc 0.737, time 23.4 sec
epoch 4, loss 0.6420, train acc 0.765, test acc 0.798, time 23.5 sec
epoch 5, loss 0.5659, train acc 0.795, test acc 0.817, time 23.4 sec

小结  

  • NiN 重复使用由卷积层和代替全连接层的1×1卷积层构成的 NiN 块来构建深层网络。  
  • NiN 去除了容易造成过拟合的全连接输出层,而是将其替换成输出通道数等于标签类别数的 NiN 块和全局平均池化层。  
  • NiN的以上设计思想影响了后面一系列卷积神经网络的设计。

练习  

(1)调节超参数,提高分类准确率。

(2)为什么 NiN 块里要有两个1×1卷积层?去除其中的一个,观察并分析实验现象。

扫码直达讨论区

在 2014 年的 ImageNet 图像识别挑战赛中,一个名叫GoogLeNet的网络结构大放异彩[54]。它虽然在名字上向 LeNet 致敬,但在网络结构上已经很难看到 LeNet 的影子。GoogLeNet 吸收了 NiN 中网络串联网络的思想,并在此基础上做了很大改进。在随后的几年里,研究人员对 GoogLeNet 进行了数次改进,本节将介绍这个模型系列的第一个版本。

GoogLeNet中的基础卷积块叫作Inception块,得名于同名电影《盗梦空间》(Inception)。与上一节介绍的NiN块相比,这个基础块在结构上更加复杂,如图5-8所示。

图5-8 Inception块的结构

由图5-8可以看出,Inception块里有4条并行的线路。前3条线路使用窗口大小分别是1×1、3×3和5×5的卷积层来抽取不同空间尺寸下的信息,其中中间2个线路会对输入先做1×1卷积来减少输入通道数,以降低模型复杂度。第四条线路则使用3×3最大池化层,后接1×1卷积层来改变通道数。4条线路都使用了合适的填充来使输入与输出的高和宽一致。最后我们将每条线路的输出在通道维上连结,并输入接下来的层中去。

Inception块中可以自定义的超参数是每个层的输出通道数,我们以此来控制模型复杂度。

In [1]: import d2lzh as d2l
        from mxnet import gluon, init, nd
        from mxnet.gluon import nn

        class Inception(nn.Block):
            # c1 - c4为每条线路里的层的输出通道数
            def __init__(self, c1, c2, c3, c4, **kwargs):
                super(Inception, self).__init__(**kwargs)
                # 线路1, 单1 x 1卷积层
                self.p1_1 = nn.Conv2D(c1, kernel_size=1, activation='relu')
                # 线路2, 1 x 1卷积层后接3 x 3卷积层
                self.p2_1 = nn.Conv2D(c2[0], kernel_size=1, activation='relu')
                self.p2_2 = nn.Conv2D(c2[1], kernel_size=3, padding=1,
                                      activation='relu')
                # 线路3, 1 x 1卷积层后接5 x 5卷积层
                self.p3_1 = nn.Conv2D(c3[0], kernel_size=1, activation='relu')
                self.p3_2 = nn.Conv2D(c3[1], kernel_size=5, padding=2,
                                      activation='relu')
                # 线路4, 3 x 3最大池化层后接1 x 1卷积层
                self.p4_1 = nn.MaxPool2D(pool_size=3, strides=1, padding=1)
                self.p4_2 = nn.Conv2D(c4, kernel_size=1, activation='relu')

            def forward(self, x): 
                p1 = self.p1_1(x)
                p2 = self.p2_2(self.p2_1(x)) 
                p3 = self.p3_2(self.p3_1(x)) 
                p4 = self.p4_2(self.p4_1(x))
                return nd.concat(p1, p2, p3, p4, dim=1) # 在通道维上连结输出

GoogLeNet 跟 VGG 一样,在主体卷积部分中使用5个模块(block),每个模块之间使用步幅为2的3×3最大池化层来减小输出高宽。第一模块使用一个 64 通道的7×7卷积层。

In [2]: b1 = nn.Sequential()
        b1.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3, activation='relu'), 
               nn.MaxPool2D(pool_size=3, strides=2, padding=1))

第二模块使用2个卷积层:首先是 64 通道的1×1卷积层,然后是将通道增大3倍的3×3卷积层。它对应 Inception 块中的第二条线路。

In [3]: b2 = nn.Sequential() 
        b2.add(nn.Conv2D(64, kernel_size=1, activation='relu'),
               nn.Conv2D(192, kernel_size=3, padding=1, activation='relu'), 
               nn.MaxPool2D(pool_size=3, strides=2, padding=1))

第三模块串联2个完整的 Inception 块。第一个 Inception 块的输出通道数为 64+128+32+32=256,其中4条线路的输出通道数比例为64 : 128 : 32 : 32=2 : 4 : 1 : 1。其中第二、第三条线路先分别将输入通道数减小至96/192=1/2和16/192=1/12后,再接上第二层卷积层。第二个 Inception块输出通道数增至 128=192=96=64=480,每条线路的输出通道数之比为 128 : 192 : 96 : 64=4 : 6 : 3 : 2。其中第二、第三条线路先分别将输入通道数减小至128/256=1/2和32/256=1/8。

In [4]: b3 = nn.Sequential()
        b3.add(Inception(64, (96, 128), (16, 32), 32),
               Inception(128, (128, 192), (32, 96), 64),
               nn.MaxPool2D(pool_size=3, strides=2, padding=1))

第四模块更加复杂。它串联了5个Inception 块,其输出通道数分别是192+208+48+64=512、160+224+64+64=512、128+256+64+64=512、112+288+64+64=528 和256+320+128+128=832。这些线路的通道数分配和第三模块中的类似,首先是含3×3卷积层的第二条线路输出最多通道,其次是仅含1×1卷积层的第一条线路,之后是含5×5卷积层的第三条线路和含3×3最大池化层的第四条线路。其中第二、第三条线路都会先按比例减小通道数。这些比例在各个 Inception 块中都略有不同。

In [5]: b4 = nn.Sequential()
        b4.add(Inception(192, (96, 208), (16, 48), 64),
               Inception(160, (112, 224), (24, 64), 64),
               Inception(128, (128, 256), (24, 64), 64),
               Inception(112, (144, 288), (32, 64), 64),
               Inception(256, (160, 320), (32, 128), 128),
               nn.MaxPool2D(pool_size=3, strides=2, padding=1))

第五模块有输出通道数为256+320+128+128=832 和 384+384+128+128=1024的两个Inception块。其中每条线路的通道数的分配思路和第三、第四模块中的一致,只是在具体数值上有所不同。需要注意的是,第五模块的后面紧跟输出层,该模块同NiN一样使用全局平均池化层来将每个通道的高和宽变成1。最后我们将输出变成二维数组后接上一个输出个数为标签类别数的全连接层。

In [6]: b5 = nn.Sequential()
        b5.add(Inception(256, (160, 320), (32, 128), 128),
               Inception(384, (192, 384), (48, 128), 128),
               nn.GlobalAvgPool2D())

        net = nn.Sequential()
        net.add(b1, b2, b3, b4, b5, nn.Dense(10))

GoogLeNet模型的计算复杂,而且不如VGG那样便于修改通道数。本节里我们将输入的高和宽从224降到96来简化计算。下面演示各个模块之间的输出的形状变化。

In [7]: X = nd.random.uniform(shape=(1, 1, 96, 96)) 
        net.initialize()
        for layer in net: 
            X = layer(X)
            print(layer.name, 'output shape:\t', X.shape)

sequential0 output shape:        (1, 64, 24, 24)
sequential1 output shape:        (1, 192, 12, 12)
sequential2 output shape:        (1, 480, 6, 6)
sequential3 output shape:        (1, 832, 3, 3)
sequential4 output shape:        (1, 1024, 1, 1)
dense0 output shape:     (1, 10)

我们使用高和宽均为96像素的图像来训练GoogLeNet模型。训练使用的图像依然来自Fashion-MNIST数据集。

In [8]: lr, num_epochs, batch_size, ctx = 0.1, 5, 128, d2l.try_gpu() 
        net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 1.7187, train acc 0.357, test acc 0.727, time 27.1 sec
epoch 2, loss 0.5887, train acc 0.780, test acc 0.830, time 23.8 sec
epoch 3, loss 0.4362, train acc 0.835, test acc 0.862, time 23.5 sec
epoch 4, loss 0.3698, train acc 0.860, test acc 0.868, time 23.5 sec
epoch 5, loss 0.3336, train acc 0.874, test acc 0.885, time 23.7 sec

小结  

  • Inception块相当于一个有4条线路的子网络。它通过不同窗口形状的卷积层和最大池化层来并行抽取信息,并使用1×1卷积层减少通道数从而降低模型复杂度。  
  • GoogLeNet将多个设计精细的Inception块和其他层串联起来。其中Inception块的通道数分配之比是在ImageNet数据集上通过大量的实验得来的。  
  • GoogLeNet和它的后继者们一度是ImageNet上最高效的模型之一:在类似的测试精度下,它们的计算复杂度往往更低。

练习  

(1)GoogLeNet有数个后续版本。尝试实现并运行它们,然后观察实验结果。这些后续版本包括加入批量归一化层(5.10节将介绍)[25]、对Inception块做调整[55]和加入残差连接(5.11节将介绍)[53]

(2)对比AlexNet、VGG和NiN、GoogLeNet的模型参数尺寸。为什么后两个网络可以显著减小模型参数尺寸?

扫码直达讨论区

本节我们介绍批量归一化(batch normalization)层,它能让较深的神经网络的训练变得更加容易[25]。在3.16节里,我们对输入数据做了标准化处理:处理后的任意一个特征在数据集中所有样本上的均值为0、标准差为1。标准化处理输入数据使各个特征的分布相近:这往往更容易训练出有效的模型。

通常来说,数据标准化预处理对于浅层模型就足够有效了。随着模型训练的进行,当每层中参数更新时,靠近输出层的输出较难出现剧烈变化。但对深层神经网络来说,即使输入数据已做标准化,训练中模型参数的更新依然很容易造成靠近输出层输出的剧烈变化。这种计算数值的不稳定性通常令我们难以训练出有效的深度模型。

批量归一化的提出正是为了应对深度模型训练的挑战。在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。批量归一化和5.11节将要介绍的残差网络为训练和设计深度模型提供了两类重要思路。

对全连接层和卷积层做批量归一化的方法稍有不同。下面我们将分别介绍这两种情况下的批量归一化。

1.对全连接层做批量归一化

我们先考虑如何对全连接层做批量归一化。通常,我们将批量归一化层置于全连接层中的仿射变换和激活函数之间。设全连接层的输入为u,权重参数和偏差参数分别为Wb,激活函数为。设批量归一化的运算符为BN。那么,使用批量归一化的全连接层的输出为

其中批量归一化输入x由仿射变换

得到。考虑一个由m个样本组成的小批量,仿射变换的输出为一个新的小批量。它们正是批量归一化层的输入。对于小批量中任意样本 ,批量归一化层的输出同样是d维向量

并由以下几步求得。首先,对小批量求均值和方差:

其中的平方计算是按元素求平方。接下来,使用按元素开方和按元素除法对 标准化:

这里是一个很小的常数,保证分母大于0。在上面标准化的基础上,批量归一化层引入了两个可以学习的模型参数,拉伸(scale)参数γ和偏移(shift)参数β。这两个参数和形状相同,皆为d维向量。它们与 分别做按元素乘法(符号 )和加法计算:

至此,我们得到了的批量归一化的输出。值得注意的是,可学习的拉伸和偏移参数保留了不对 做批量归一化的可能:此时只需学出 。我们可以对此这

样理解:如果批量归一化无益,理论上讲,学出的模型可以不使用批量归一化。

2.对卷积层做批量归一化

对卷积层来说,批量归一化发生在卷积计算之后、应用激活函数之前。如果卷积计算输出多个通道,我们需要对这些通道的输出分别做批量归一化,且每个通道都拥有独立的拉伸和偏移参数,并均为标量。设小批量中有m个样本。在单个通道上,假设卷积计算输出的高和宽分别为pq。我们需要对该通道中个元素同时做批量归一化。对这些元素做标准化计算时,我们使用相同的均值和方差,即该通道中个元素的均值和方差。

3.预测时的批量归一化

使用批量归一化训练时,我们可以将批量大小设得大一点,从而使批量内样本的均值和方差的计算都较为准确。将训练好的模型用于预测时,我们希望模型对于任意输入都有确定的输出。因此,单个样本的输出不应取决于批量归一化所需要的随机小批量中的均值和方差。一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出。可见,和丢弃层一样,批量归一化层在训练模式和预测模式下的计算结果也是不一样的。

下面我们通过NDArray来实现批量归一化层。

In [1]: import d2lzh as d2l
        from mxnet import autograd, gluon, init, nd
        from mxnet.gluon import nn

        def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
            # 通过autograd来判断当前模式是训练模式还是预测模式
            if not autograd.is_training():
                # 如果是在预测模式下, 直接使用传入的移动平均所得的均值和方差
                X_hat = (X - moving_mean) / nd.sqrt(moving_var + eps)
            else:
                assert len(X.shape) in (2, 4)
                if len(X.shape) == 2:
                    # 使用全连接层的情况, 计算特征维上的均值和方差
                    mean = X.mean(axis=0)
                    var = ((X - mean) ** 2).mean(axis=0)
                else:
                    # 使用二维卷积层的情况, 计算通道维上(axis=1)的均值和方差。这里我们需要保持
                    # X的形状以便后面可以做广播运算
                    mean = X.mean(axis=(0, 2, 3), keepdims=True)
                    var = ((X - mean) ** 2).mean(axis=(0, 2, 3), keepdims=True)
                # 训练模式下用当前的均值和方差做标准化
                X_hat = (X - mean) / nd.sqrt(var + eps)
                # 更新移动平均的均值和方差
                moving_mean = momentum * moving_mean + (1.0 - momentum) * mean 
                moving_var = momentum * moving_var + (1.0 - momentum) * var
            Y = gamma * X_hat + beta # 拉伸和偏移
            return Y, moving_mean, moving_var

接下来,我们自定义一个BatchNorm层。它保存参与求梯度和迭代的拉伸参数gamma和偏移参数beta,同时也维护移动平均得到的均值和方差,以便能够在模型预测时被使用。BatchNorm实例所需指定的num_features参数对于全连接层来说应为输出个数,对于卷积层来说则为输出通道数。该实例所需指定的num_dims参数对于全连接层和卷积层来说分别为2和4。

In [2]: class BatchNorm(nn.Block):
            def __init__(self, num_features, num_dims, **kwargs):
                super(BatchNorm, self).__init__(**kwargs)
                if num_dims == 2:
                    shape = (1, num_features)
                else:
                    shape = (1, num_features, 1, 1)
                # 参与求梯度和迭代的拉伸和偏移参数, 分别初始化成0和1
                self.gamma = self.params.get('gamma', shape=shape, init=init.One())
                self.beta = self.params.get('beta', shape=shape, init=init.Zero())
                # 不参与求梯度和迭代的变量, 全在内存上初始化成0 
                self.moving_mean = nd.zeros(shape) 
                self.moving_var = nd.zeros(shape)

            def forward(self, X):
                # 如果X不在内存上, 将moving_mean和moving_var复制到X所在显存上
                if self.moving_mean.context != X.context:
                    self.moving_mean = self.moving_mean.copyto(X.context) 
                    self.moving_var = self.moving_var.copyto(X.context)
                # 保存更新过的moving_mean和moving_var
                Y, self.moving_mean, self.moving_var = batch_norm(
                    X, self.gamma.data(), self.beta.data(), self.moving_mean, 
                    self.moving_var, eps=1e-5, momentum=0.9)
                return Y

下面我们修改5.5节介绍的LeNet模型,从而应用批量归一化层。我们在所有的卷积层或全连接层之后、激活层之前加入批量归一化层。

In [3]: net = nn.Sequential() 
        net.add(nn.Conv2D(6, kernel_size=5),
                BatchNorm(6, num_dims=4), 
                nn.Activation('sigmoid'), 
                nn.MaxPool2D(pool_size=2, strides=2), 
                nn.Conv2D(16, kernel_size=5), 
                BatchNorm(16, num_dims=4), 
                nn.Activation('sigmoid'),
                nn.MaxPool2D(pool_size=2, strides=2), 
                nn.Dense(120),
                BatchNorm(120, num_dims=2), 
                nn.Activation('sigmoid'), 
                nn.Dense(84),
                BatchNorm(84, num_dims=2), 
                nn.Activation('sigmoid'), 
                nn.Dense(10))

下面我们训练修改后的模型。

In [4]: lr, num_epochs, batch_size, ctx = 1.0, 5, 256, d2l.try_gpu() 
        net.initialize(ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 0.6675, train acc 0.760, test acc 0.824, time 3.6 sec
epoch 2, loss 0.3946, train acc 0.858, test acc 0.813, time 3.4 sec
epoch 3, loss 0.3477, train acc 0.874, test acc 0.740, time 3.3 sec
epoch 4, loss 0.3215, train acc 0.884, test acc 0.867, time 3.3 sec
epoch 5, loss 0.3015, train acc 0.890, test acc 0.823, time 3.4 sec

最后我们查看第一个批量归一化层学习到的拉伸参数gamma和偏移参数beta

In [5]: net[1].gamma.data().reshape((-1,)), net[1].beta.data().reshape((-1,))

Out[5]: (
         [2.0340614 1.5274717 1.7007711 1.2053087 1.5917673 1.7429659]
         <NDArray 6 @gpu(0)>,
         [ 1.1765741   0.02335754 0.4149146  0.60519356 -0.2102287 -1.936496 ]
         <NDArray 6 @gpu(0)>)

与我们刚刚自己定义的BatchNorm类相比,Gluon中nn模块定义的BatchNorm类使用起来更加简单。它不需要指定自己定义的BatchNorm类中所需的num_featuresnum_dims参数值。在Gluon中,这些参数值都将通过延后初始化而自动获取。下面我们用Gluon实现使用批量归一化的LeNet。

In [6]: net = nn.Sequential() 
        net.add(nn.Conv2D(6, kernel_size=5),
        nn.BatchNorm(), 
        nn.Activation('sigmoid'), 
        nn.MaxPool2D(pool_size=2, strides=2), 
        nn.Conv2D(16, kernel_size=5), 
        nn.BatchNorm(), 
        nn.Activation('sigmoid'), 
        nn.MaxPool2D(pool_size=2, strides=2), 
        nn.Dense(120),
        nn.BatchNorm(), 
        nn.Activation('sigmoid'), 
        nn.Dense(84), 
        nn.BatchNorm(), 
        nn.Activation('sigmoid'), 
        nn.Dense(10))

使用同样的超参数进行训练。

In [7]: net.initialize(ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 0.6382, train acc 0.774, test acc 0.833, time 2.1 sec
epoch 2, loss 0.3904, train acc 0.859, test acc 0.854, time 2.1 sec
epoch 3, loss 0.3448, train acc 0.875, test acc 0.855, time 1.9 sec
epoch 4, loss 0.3198, train acc 0.884, test acc 0.842, time 2.0 sec
epoch 5, loss 0.2970, train acc 0.891, test acc 0.880, time 2.1 sec

小结  

  • 在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络的中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。  
  • 对全连接层和卷积层做批量归一化的方法稍有不同。  
  • 批量归一化层和丢弃层一样,在训练模式和预测模式的计算结果是不一样的。  
  • Gluon提供的BatchNorm类使用起来简单、方便。

练习  

(1)能否将批量归一化前的全连接层或卷积层中的偏差参数去掉?为什么?(提示:回忆批量归一化中标准化的定义。)

(2)尝试调大学习率。同5.5节中未使用批量归一化的LeNet相比,现在是不是可以使用更大的学习率?

(3)尝试将批量归一化层插入LeNet的其他地方,观察并分析结果的变化。

(4)尝试一下不学习拉伸参数gamma和偏移量参数beta(构造的时候加入参数grad_req='null'来避免计算梯度),观察并分析结果。

(5)查看BatchNorm类的文档来了解更多使用方法,例如,如何在训练时使用基于全局平均的均值和方差。

让我们先思考一个问题:对神经网络模型添加新的层,充分训练后的模型是否只可能更有效地降低训练误差?理论上,原模型解的空间只是新模型解的空间的子空间。也就是说,如果我们能将新添加的层训练成恒等映射,新模型和原模型将同样有效。由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差。然而在实践中,添加过多的层后训练误差往往不降反升。即使利用批量归一化带来的数值稳定性使训练深层模型更加容易,该问题仍然存在。针对这一问题,何恺明等人提出了残差网络ResNet[19]。它在2015年的ImageNet图像识别挑战赛夺魁,并深刻影响了后来的深度神经网络的设计。

让我们聚焦于神经网络局部。如图5-9所示,设输入为x。假设我们希望学出的理想映射为,从而作为图5-9最上方激活函数的输入。左图虚线框中的部分需要直接拟合出该映射,而右图虚线框中的部分则需要拟合出有关恒等映射的残差映射。残差映射在实际中往往更容易优化。以本节开头提到的恒等映射作为我们希望学出的理想映射。我们只需将图5-9中右图虚线框内上方的加权运算(如仿射)的权重和偏差参数学成0,那么即为恒等映射。实际中,当理想映射极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。图5-9右图也是ResNet的基础块,即残差块(residual block)。在残差块中,输入可通过跨层的数据线路更快地向前传播。

图5-9 设输入为x。假设图中最上方激活函数输入的理想映射为。左图虚线框中的部分需要直接拟合出该映射,而右图虚线框中的部分需要拟合出有关恒等映射的残差映射

ResNet沿用了VGG全3×3卷积层的设计。残差块里首先有2个有相同输出通道数的3×3卷积层。每个卷积层后接一个批量归一化层和ReLU激活函数。然后我们将输入跳过这2个卷积运算后直接加在最后的ReLU激活函数前。这样的设计要求2个卷积层的输出与输入形状一样,从而可以相加。如果想改变通道数,就需要引入一个额外的1×1卷积层来将输入变换成需要的形状后再做相加运算。

残差块的实现如下。它可以设定输出通道数、是否使用额外的1×1卷积层来修改通道数以及卷积层的步幅。

In [1]: import d2lzh as d2l
        from mxnet import gluon, init, nd
        from mxnet.gluon import nn

        class Residual(nn.Block): # 本类已保存在d2lzh包中方便以后使用
            def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs):
                super(Residual, self).__init__(**kwargs)
                self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1, 
                                       strides=strides)
                self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1)
                if use_1x1conv:
                    self.conv3 = nn.Conv2D(num_channels, kernel_size=1,
                                           strides=strides)
                else:
                    self.conv3 = None 
                self.bn1 = nn.BatchNorm() 
                self.bn2 = nn.BatchNorm()

            def forward(self, X):
                Y = nd.relu(self.bn1(self.conv1(X))) 
                Y = self.bn2(self.conv2(Y))
                if self.conv3:
                    X = self.conv3(X)
                return nd.relu(Y + X) 

下面我们来查看输入和输出形状一致的情况。

In [2]: blk = Residual(3)
        blk.initialize()
        X = nd.random.uniform(shape=(4, 3, 6, 6)) 
        blk(X).shape

Out[2]: (4, 3, 6, 6)

我们也可以在增加输出通道数的同时减半输出的高和宽。

In [3]: blk = Residual(6, use_1x1conv=True, strides=2) 
        blk.initialize()
        blk(X).shape

Out[3]: (4, 6, 3, 3)

ResNet的前两层跟之前介绍的GoogLeNet中的一样:在输出通道数为64、步幅为2的7×7卷积层后接步幅为2的3×3的最大池化层。不同之处在于ResNet每个卷积层后增加的批量归一化层。

In [4]: net = nn.Sequential()
        net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3), 
                nn.BatchNorm(), nn.Activation('relu'), 
                nn.MaxPool2D(pool_size=3, strides=2, padding=1))

GoogLeNet在后面接了4个由Inception块组成的模块。ResNet则使用4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。第一个模块的通道数同输入通道数一致。由于之前已经使用了步幅为2的最大池化层,所以无须减小高和宽。之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。

下面我们来实现这个模块。注意,这里对第一个模块做了特别处理。

In [5]: def resnet_block(num_channels, num_residuals, f irst_block=False): 
            blk = nn.Sequential()
            for i in range(num_residuals):
                if i == 0 and not f irst_block:
                    blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
                else:
                    blk.add(Residual(num_channels))
            return blk 

接着我们为ResNet加入所有残差块。这里每个模块使用2个残差块。

In [6]: net.add(resnet_block(64, 2, f irst_block=True),
                resnet_block(128, 2),
                resnet_block(256, 2),
                resnet_block(512, 2))

最后,与GoogLeNet一样,加入全局平均池化层后接上全连接层输出。

In [7]: net.add(nn.GlobalAvgPool2D(), nn.Dense(10))

这里每个模块里有4个卷积层(不计算1×1卷积层),加上最开始的卷积层和最后的全连接层,共计18层。这个模型通常也被称为ResNet-18。通过配置不同的通道数和模块里的残差块数可以得到不同的ResNet模型,例如更深的含152层的ResNet-152。虽然ResNet的主体架构跟GoogLeNet的类似,但ResNet结构更简单,修改也更方便。这些因素都导致了ResNet迅速被广泛使用。

在训练ResNet之前,我们来观察一下输入形状在ResNet不同模块之间的变化。

In [8]: X = nd.random.uniform(shape=(1, 1, 224, 224)) 
        net.initialize()
        for layer in net: 
            X = layer(X)
            print(layer.name, 'output shape:\t', X.shape)

conv5 output shape:      (1, 64, 112, 112)
batchnorm4 output shape:         (1, 64, 112, 112)
relu0 output shape:      (1, 64, 112, 112)
pool0 output shape:      (1, 64, 56, 56)
sequential1 output shape:        (1, 64, 56, 56)
sequential2 output shape:        (1, 128, 28, 28)
sequential3 output shape:        (1, 256, 14, 14)
sequential4 output shape:        (1, 512, 7, 7)
pool1 output shape:      (1, 512, 1, 1)
dense0 output shape:     (1, 10)

下面我们在Fashion-MNIST数据集上训练ResNet。

In [9]: lr, num_epochs, batch_size, ctx = 0.05, 5, 256, d2l.try_gpu() 
        net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 0.4848, train acc 0.829, test acc 0.890, time 15.7 sec
epoch 2, loss 0.2539, train acc 0.906, test acc 0.910, time 14.4 sec
epoch 3, loss 0.1909, train acc 0.930, test acc 0.916, time 14.4 sec
epoch 4, loss 0.1442, train acc 0.947, test acc 0.919, time 14.3 sec
epoch 5, loss 0.1072, train acc 0.962, test acc 0.912, time 14.4 sec

小结  

  • 残差块通过跨层的数据通道从而能够训练出有效的深度神经网络。  
  • ResNet深刻影响了后来的深度神经网络的设计。

练习  

(1)参考ResNet论文的表1来实现不同版本的ResNet [19]

(2)对于比较深的网络,ResNet论文中介绍了一个“瓶颈”架构来降低模型复杂度。尝试实现它[19]

(3)在ResNet的后续版本里,作者将残差块里的“卷积、批量归一化和激活”结构改成了“批量归一化、激活和卷积”,实现这个改进(见参考文献[20],图1)。

扫码直达讨论区

ResNet中的跨层连接设计引申出了数个后续工作。本节介绍其中的一个——稠密连接网络(DenseNet)[24]。它与ResNet的主要区别如图5-10所示。

图5-10 ResNet(左)与DenseNet(右)在跨层连接上的主要区别:使用相加和使用连结

图5-10中将部分前后相邻的运算抽象为模块A和模块B。与ResNet的主要区别在于,DenseNet里模块B的输出不是像ResNet那样和模块A的输出相加,而是在通道维上连结。这样模块A的输出可以直接传入模块B后面的层。在这个设计里,模块A直接跟模块B后面的所有层连接在了一起。这也是它被称为“稠密连接”的原因。

DenseNet的主要构建模块是稠密块(dense block)和过渡层(transition layer)。前者定义了输入和输出是如何连结的,后者则用来控制通道数,使之不过大。

DenseNet使用了ResNet改良版的“批量归一化、激活和卷积”结构(参见5.11节的练习),我们首先在conv_block函数里实现这个结构。

In [1]: import d2lzh as d2l
        from mxnet import gluon, init, nd
        from mxnet.gluon import nn

        def conv_block(num_channels): 
            blk = nn.Sequential()
            blk.add(nn.BatchNorm(), nn.Activation('relu'), 
                    nn.Conv2D(num_channels, kernel_size=3, padding=1))
            return blk

稠密块由多个conv_block组成,每块使用相同的输出通道数。但在前向计算时,我们将每块的输入和输出在通道维上连结。

In [2]: class DenseBlock(nn.Block):
            def __init__(self, num_convs, num_channels, **kwargs): 
                super(DenseBlock, self).__init__(**kwargs) 
                self.net = nn.Sequential()
                for _ in range(num_convs): 
                    self.net.add(conv_block(num_channels))

            def forward(self, X):
                for blk in self.net: 
                    Y = blk(X)
                    X = nd.concat(X, Y, dim=1) # 在通道维上将输入和输出连结
                return X

在下面的例子中,我们定义一个有2个输出通道数为10的卷积块。使用通道数为3的输入时,我们会得到通道数为 的输出。卷积块的通道数控制了输出通道数相对于输入通道数的增长,因此也被称为增长率(growth rate)。

In [3]: blk = DenseBlock(2, 10) 
        blk.initialize()
        X = nd.random.uniform(shape=(4, 3, 8, 8)) 
        Y = blk(X)
        Y.shape
Out[3]: (4, 23, 8, 8)

由于每个稠密块都会带来通道数的增加,使用过多则会带来过于复杂的模型。过渡层用来控制模型复杂度。它通过1×1卷积层来减小通道数,并使用步幅为2的平均池化层减半高和宽,从而进一步降低模型复杂度。

In [4]: def transition_block(num_channels): 
            blk = nn.Sequential()
            blk.add(nn.BatchNorm(), nn.Activation('relu'), 
                    nn.Conv2D(num_channels, kernel_size=1), 
                    nn.AvgPool2D(pool_size=2, strides=2))
            return blk

对上一个例子中稠密块的输出使用通道数为10的过渡层。此时输出的通道数减为10,高和宽均减半。

In [5]: blk = transition_block(10) 
        blk.initialize() 
        blk(Y).shape
Out[5]: (4, 10, 4, 4)

我们来构造DenseNet模型。DenseNet首先使用同ResNet一样的单卷积层和最大池化层。

In [6]: net = nn.Sequential()
        net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3), 
                nn.BatchNorm(), nn.Activation('relu'), 
                nn.MaxPool2D(pool_size=3, strides=2, padding=1))

类似于ResNet接下来使用的4个残差块,DenseNet使用的是4个稠密块。同ResNet一样,我们可以设置每个稠密块使用多少个卷积层。这里我们设成4,从而与5.11节的ResNet-18保持一致。稠密块里的卷积层通道数(即增长率)设为32,所以每个稠密块将增加128 个通道。

ResNet里通过步幅为2的残差块在每个模块之间减小高和宽。这里我们则使用过渡层来减半高和宽,并减半通道数。

In [7]: num_channels, growth_rate = 64, 32 # num_channels为当前的通道数
        num_convs_in_dense_blocks = [4, 4, 4, 4]

        for i, num_convs in enumerate(num_convs_in_dense_blocks): 
            net.add(DenseBlock(num_convs, growth_rate))
            # 上一个稠密块的输出通道数
            num_channels += num_convs * growth_rate
            # 在稠密块之间加入通道数减半的过渡层
            if i != len(num_convs_in_dense_blocks) - 1: 
               num_channels //= 2
               net.add(transition_block(num_channels))

同ResNet一样,最后接上全局池化层和全连接层来输出。

In [8]: net.add(nn.BatchNorm(), nn.Activation('relu'), nn.GlobalAvgPool2D(), 
                nn.Dense(10))

由于这里使用了比较深的网络,本节里我们将输入高和宽从224降到96来简化计算。

In [9]: lr, num_epochs, batch_size, ctx = 0.1, 5, 256, d2l.try_gpu()
        net.initialize(ctx=ctx, init=init.Xavier())
        trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) 
        train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96) 
        d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
                      num_epochs)

training on gpu(0)
epoch 1, loss 0.5387, train acc 0.808, test acc 0.862, time 14.7 sec
epoch 2, loss 0.3157, train acc 0.885, test acc 0.875, time 13.0 sec
epoch 3, loss 0.2646, train acc 0.903, test acc 0.891, time 13.0 sec
epoch 4, loss 0.2375, train acc 0.914, test acc 0.899, time 13.0 sec
epoch 5, loss 0.2124, train acc 0.923, test acc 0.915, time 13.0 sec

小结  

  • 在跨层连接上,不同于ResNet中将输入与输出相加,DenseNet在通道维上连结输入与输出。  
  • DenseNet的主要构建模块是稠密块和过渡层。

练习  

(1)DenseNet论文中提到的一个优点是模型参数比ResNet的更小,这是为什么?

(2)DenseNet被人诟病的一个问题是内存或显存消耗过多。真的会这样吗?可以把输入形状换成224224,来看看实际的消耗。

(3)实现DenseNet论文中的表1提出的不同版本的DenseNet [24]

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e49084”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


相关图书

ChatGPT原理与应用开发
ChatGPT原理与应用开发
深度学习的数学——使用Python语言
深度学习的数学——使用Python语言
深度学习:从基础到实践(上、下册)
深度学习:从基础到实践(上、下册)
动手学深度学习(PyTorch版)
动手学深度学习(PyTorch版)
深度学习与医学图像处理
深度学习与医学图像处理
深度强化学习实战:用OpenAI Gym构建智能体
深度强化学习实战:用OpenAI Gym构建智能体

相关文章

相关课程