破解深度学习(核心篇):模型算法与实现

978-7-115-65103-7
作者: 瞿炜
译者:
编辑: 吴晋瑜

图书目录:

详情

本书旨在采用一种符合读者认知角度且能提升其学习效率的方式来讲解深度学习背后的核心知识、原理和内在逻辑。 经过基础篇的学习,想必你已经对深度学习的总体框架有了初步的了解和认识,掌握了深度神经网络从核心概念、常见问题到典型网络的基本知识。本书为核心篇,将带领读者实现从入门到进阶、从理论到实战的跨越。全书共7章,前三章包括复杂CNN、RNN和注意力机制网络,深入详解各类主流模型及其变体;第4章介绍这三类基础模型的组合体,即概率生成模型;第5章和第6章着重介绍这些复杂模型在计算机视觉和自然语言处理两大最常见领域的应用;第7章讲解生成式大语言模型的内在原理并对其发展趋势予以展望。 本书系统全面,深入浅出,且辅以生活中的案例进行类比,以此降低学习难度,能够帮助读者迅速掌握深度学习的基础知识。本书适合有志于投身人工智能领域的人员阅读,也适合作为高等院校人工智能相关专业的教学用书。

图书摘要

版权信息

书名:破解深度学习(核心篇):模型算法与实现

ISBN:978-7-115-65103-7

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

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

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

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

版  权

著    瞿 炜 李 力 杨 洁

责任编辑 吴晋瑜

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

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

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

读者服务热线:(010)81055410

反盗版热线:(010)81055315

内 容 提 要

本书旨在采用一种符合读者认知角度且能提升其学习效率的方式来讲解深度学习背后的核心知识、原理和内在逻辑。

经过基础篇的学习,想必你已经对深度学习的总体框架有了初步的了解和认识,掌握了深度神经网络从核心概念、常见问题到典型网络的基本知识。本书为核心篇,将带领读者实现从入门到进阶、从理论到实战的跨越。全书共7章,前三章包括复杂CNN、RNN和注意力机制网络,深入详解各类主流模型及其变体;第4章介绍这三类基础模型的组合体,即概率生成模型;第5章和第6章着重介绍这些复杂模型在计算机视觉和自然语言处理两大最常见领域的应用;第7章讲解生成式大语言模型的内在原理并对其发展趋势予以展望。

本书系统全面,深入浅出,且辅以生活中的案例进行类比,以此降低学习难度,能够帮助读者迅速掌握深度学习的基础知识。本书适合有志于投身人工智能领域的人员阅读,也适合作为高等院校人工智能相关专业的教学用书。

作者简介

瞿炜,美国伊利诺伊大学人工智能博士,哈佛大学、京都大学客座教授;前中国科学院大学教授、模式识别国家重点实验室客座研究员;国家部委特聘专家、重点实验室学术委员会委员;知名国际期刊编委,多个顶级学术期刊审稿人及国际学术会议委员。

在人工智能业界拥有二十余年的技术积累和实践经验,曾先后在互联网、医疗、安防、教育等行业的多家世界500强企业担任高管。他是北京授业解惑科技有限公司的创始人,以及多家人工智能、金融公司的联合创始人,还是一名天使投资人。

凭借多年的专业积淀和卓越的行业洞察力,瞿炜博士近年来致力于人工智能教育事业的发展。作为知名教育博主,他擅长用通俗易懂的表达方式结合直观生动的模型动画,讲述复杂的人工智能理论与算法;创作的人工智能系列视频和课程在B站(账号:梗直哥丶)/知乎/公众号/视频号(账号:梗直哥丶)等平台深受学生们的欢迎和认可,累计访问量超数千万人次。

李力,人工智能专家,长期致力于计算机视觉和强化学习领域的研究与实践。曾在多家顶尖科技企业担任资深算法工程师,拥有十余年行业经验,具备丰富的技术能力和深厚的理论知识。在职业生涯中,李力参与并领导了众多深度学习和强化学习的核心技术项目,有效地应用先进模型解决图像识别、目标检测、自然语言处理、机器人研发等多个领域的实际问题。

杨洁,人工智能和自然语言处理领域资深应用专家,在自然语言理解、基于知识的智能服务、跨模态语言智能、智能问答系统等技术领域具有深厚的实战背景。她曾在教育、医疗等行业的知名企业担任关键职位,拥有十年以上的行业管理经验,成功领导并实施了多个创新项目,擅长引领团队将复杂的理论转化为实际应用,解决行业中的关键问题。

前  言

过去十年,我们见证了深度学习的蓬勃发展,见证了深度学习在自然语言处理、计算机视觉、多模态内容生成、自动驾驶等方向取得的巨大成功,并成为人工智能最热门的领域之一。当前,越来越多的学习者投身于深度学习技术领域,力图提升自己的专业技能,增强自己在就业市场上的竞争力,成为市场上最抢手的人才。

但是,如何在短时间内快速入门并掌握深度学习,是很多读者的困惑—晦涩的数学知识、复杂的算法、烦琐的编程……深度学习虽然使无数读者心怀向往,却也让不少人望而生畏,深感沮丧:时间没少花,却收效甚微。

目前,大多数深度学习的图书在某种程度上都是在“端着”讲,习惯于从专家视角出发,而没有充分考虑初学者的认知程度,这导致读者阅之如看天书,食之如嚼蜡。再者,即使是专业人士,面对领域内的最新进展,也往往苦于找不到难度适宜又系统全面的教材,只能求助于英文学术论文、技术文章和视频网站,由此浪费了大量时间和精力。我们始终觉得,真正的学习不应该让学习者倍感煎熬,而应该是一件让人愉悦,且能带来成就感的事情。深度学习之所以能把人劝退,往往是教者不擅教、学者又不会学导致的。

说了这么多,你肯定好奇,本书有什么与众不同呢?在过去的几年里,我们一直在思考如何才能更好地教授深度学习这门课程。为此,我们在AI教育领域进行了积极的探索和创新,积累了一些经验,并赢得了业界和用户的高度认可。这套书 就是我们在深度学习领域的探索和实践成果,它最主要的特色有两个:“只说人话”和“突出实战”。具体而言,本书在如下方面有所侧重并作了差异化处理。

① 共两册,即《破解深度学习(基础篇):模型算法与实现》和《破解深度学习(核心篇):模型算法与实现》

内容重构、全面细致

我们根据ACM和IEEE最新版人工智能体系的111个知识点,参考各类优秀资料,对深度学习理论进行了全面梳理,力求用一套书囊括从20世纪90年代到目前为止的大部分主流模型,让读者一书在手,就能够建立有关深度学习的全局知识框架,而不用再“东奔西走”。对于算法的讲解,我们不会只局限于算法自身,而是会从全局视角分析其中的内在联系和区别。我们会将知识点掰开揉碎讲清楚,充分剖析重点和难点,让它不再难以理解掌握。

算法与代码紧密结合

这套书在引入任何新概念时,都辅之以简单易懂、贴近生活的示例,以期帮助读者降低理解难度,进而知道为什么要学习这个算法、数学公式怎么好记,以及在实际问题中怎么应用。此外,针对多数初学者“一听就会,一写就废”的情况,我们竭力提供详尽的“保姆式”教程,由简及繁,让读者敢动手,会动手,易上手。这套书配有交互式、可视化源代码示例及详尽的说明文档(以Jupyter Notebook的形式提供),提供了所有模型的完整实现,可供读者在真实数据上运行,还能亲自动手修改,方便获得直观上的体验。

形式生动,只为让你懂

看过梗直哥视频的读者都知道,形式生动是我们的大特色。很多时候,一图胜千言,而动画比静图更容易让人理解。为此,我们将秉持这一优势和特点,力求让读者彻底学懂!越是复杂的概念,我们越是要把它讲解得深入浅出。

除此之外,为保证学习效果,我们还提供了在线课程和直播课程,把内容知识点切分成10~20分钟一节,共有百节之多;通过在线答疑、直播串讲等交流形式,增强互动感,加快读者的学习速度,提升学习效果。同时,还有学员讨论群,由专业老师随时解决读者的个性化问题,充分做到因材施教。

我们通过这套书对深度学习庞杂的知识点进行了细致梳理,以期带着读者从不同维度视角鸟瞰深度学习的世界。在这套书中,我们专门针对深度学习领域抽象难懂的知识点,利用丰富的行业积淀和独特的领域视角,结合日常生活的实例,将这些高深的内容用简明、有趣的方式呈现,打破认知障碍,帮助读者轻松消化。同时,突出应用为先、实战为重的特点,为每个模型提供详尽完整的手搓代码和调库代码,由易到难层层递进。此外,这套书突破了传统图书单一的文字教学模式,采用图文、动画和视频相结合的方式,使深度学习的原理和应用场景更加直观和生动。

相信这套书能够打破读者对“深度学习学不会、入门难、不见效”的看法,帮助他们破解学习难题,快速掌握相关知识。

读者对象

这套书针对不同的读者群体(初学者、有一定经验的读者和经验丰富的读者),提供了各自对应的教学内容和方法,旨在满足各种背景和认知水平的读者,使他们能够更有效地学习、掌握深度学习技术,并应对实际挑战。

初学者群体

适合对深度学习尚未涉足或经验较少的读者,如学生、转行者或独立学习者。本书从深度学习的基本概念出发,采用通俗易懂的文字和实例,帮助读者迅速入门。同时,我们为你梳理了必要的数学、计算机以及统计学基础知识,并推荐相应的参考资料与工具,以便读者自学和巩固知识。书中内容设计为由浅入深,确保读者能够按部就班地领略深度学习的精髓。

中级群体

针对已具备深度学习基础,并且有一定实践经验的读者,比如从业者或者正在攻读相关硕士或博士学位的学生读者,本书提供了更加深入的理论和技术讲解,整合了最新的研究进展和实践案例,确保你始终走在领域前沿并更好地应对实际挑战。本书重点讲解深度神经网络的核心理念、优化算法和模型设计技巧,同时详细讲解了当下热门的深度学习框架与工具,帮助读者更好地设计、实现和部署深度学习模型。

高级群体

对已有深度学习相关领域研究或工作经验,并对前沿研究和技术保持高度关注的读者,比如研究生、博士后或者专业人士,本书提供了深入的理论和技术分析,帮助你深入挖掘深度学习的内核及其固有规律。本书涉及了深度学习的前沿研究和实践,如深度生成模型、迁移学习和多模态AI内容生成等,让你全面了解这些研究方向及其最新技术的应用现状。同时,为了方便你深入研究和探索,我们还提供了相关论文引用和代码示例。

套书特色

“只说人话”,破解难题

深度学习内容常常以概念深奥、公式难懂、算法晦涩著称,与其他图书只侧重知识传授而忽视读者的接受程度不同,我们致力于将这些高深内容转换为通俗易懂的“人话”。在我们深厚的行业经验和独特的领域洞察基础上,我们结合日常生活实例,采用一个个清晰而有趣的视角,帮你突破理解的壁垒,真正实现知识的尽情消化和良好吸收。

贴合应用,突出实战

相比其他深度学习教材,本书将算法与代码紧密结合,“手把手”教读者用深度学习的方法解决实际问题。每章都提供了Jupyter Notebook的源代码,以及所有模型的完整实现,可在真实数据上运行,更可亲自动手修改,方便获得直观上的体验。应用为先,实战为重的鲜明特点使得本书成为一本实践性强、易操作的教材,能够真正帮助读者掌握深度学习的核心算法,提升实际问题的解决能力。

图文视频,三位一体

有别于其他图书单一的说教式文字描述,本书不只局限于传统纸质图书的形式,特别注重配图、动画和视频,以更直观的方式展现模型的原理和应用场景。这种多维度的教学方式能够让读者更加深入、轻松地理解深度学习。

多元互动,个性辅导

以本书为主线内容,同时有配套的GitHub专栏课程和视频课程(收费),适合不同的读者需求和学习风格。同时,有专业的答疑团队提供在线答疑,与读者进行互动交流,解答读者疑问和提供技术支持,能够根据读者个性化的问题和困难,提供更加有针对性的辅导,加速学习进程,真正实现因材施教。

本书组织结构

本书侧重于深度学习模型算法与实现核心知识的讲解,力求用深入浅出的语言、图例、动画等多种生动的形式让初学者更加容易入门。本书总计7章,内容分别如下。

第1章 复杂卷积神经网络:捕获精细特征—本章主要回顾目前已有的经典卷积神经网络模型。通过学习,读者会对如何实现精细特征的高效捕获有更加全面的认识。

第2章 复杂循环神经网络:为记忆插上翅膀—针对序列数据处理中长期依赖等典型问题,本章将讲解长短期记忆网络、门控循环单元等经典的循环神经网络结构,以及常用的优化算法和正则化方法,力求帮助读者更好地训练和评价循环神经网络。

第3章 复杂注意力神经网络:大模型的力量—本章主要介绍的复杂注意力神经网络指大规模预训练模型,包括BERT、GPT系列、T5、ViT等,旨在让读者了解大模型的相关原理和技术,紧跟时代潮流。

第4章 深度生成模型:不确定性的妙用—本章将介绍更复杂的深度神经网络模型和基于概率统计的建模技术。我们将从常见的近似优化算法讲起,具体讲述变分自编码器、生成对抗网络、扩散模型等多个深度生成模型,让读者了解深度生成模型在内容生成领域的应用。

第5章 计算机视觉:让智慧可见—本章着重介绍深度学习在计算机视觉领域的应用,并辅以实战案例,深入剖析相关技术实现细节。

第6章 自然语言处理:人机交互懂你所说—本章着重介绍自然语言处理技术在各领域的应用,阐释人机交互、语义处理等方面的技术原理,并给出了相关数据集及实战案例。

第7章 多模态生成式人工智能:引领智能新时代—本章探索了AIGC方向前沿模型并予以趋势分析,帮助读者洞察人工智能2.0的发展方向,勇做时代弄潮儿。

资源与支持

资源获取

本书提供如下资源:

本书思维导图;

异步社区7天VIP会员。

要获得以上资源,你可以扫描下方二维码,根据指引领取。

提交勘误

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

当读者发现错误时,请登录异步社区(https://www.epubit.com),按书名搜索,进入本书页面,单击“发表勘误”,输入勘误信息,单击“提交勘误”按钮即可(见右图)。本书的作者和编辑会对读者提交的勘误进行审核,确认并接受后,将赠予读者异步社区100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

与我们联系

我们的联系邮箱是wujinyu@ptpress.com.cn。

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

如果读者有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们。

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

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

关于异步社区和异步图书

“异步社区”(www.epubit.com)是由人民邮电出版社创办的IT专业图书社区,于2015年8月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作者与读者在线交流互动,以及传统出版与数字出版的融合发展。

“异步图书”是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域多年来的发展与积淀。异步图书面向IT行业以及各行业使用IT技术的用户。

第1章 复杂卷积神经网络:捕获精细特征

在基础卷积神经网络(CNN)的学习中,读者应该对CNN的常见操作有了较为深入的理解。我们实现了CNN发展历程中重要的一步,也就是LeNet,并通过使用MNIST数据集体验了完整的CNN训练流程。然而,在LeNet问世后的十多年间,目标识别领域的主流仍然是传统算法,CNN并未得到大规模的应用,其中一大原因在于计算能力的限制。直到2012年,AlexNet在ImageNet挑战赛中取得了胜利,CNN重新引起了人们的关注,并由此揭开了快速发展和不断创新的序幕。

先来回顾一下经典的CNN模型。图1-1给出了主流CNN模型发展的里程碑。

图1-1 主流CNN模型发展示意

1998年由Yann LeCun等人提出的LeNet作为CNN的先驱,是手写数字识别问题的基础模型。

2012年Alex Krizhevsky等人的AlexNet模型在ImageNet图像识别比赛中取得了显著成果,使得CNN成为当时最先进的图像识别模型,奠定了深度学习在计算机视觉领域中的地位。

2014年,VGGNet凭借着更深更宽的网络结构取得ILSVRC竞赛定位任务第一名。同年,GoogLeNet采用了能更高效利用计算资源的Inception模块,在ILSVRC的分类任务上击败VGGNet夺得冠军。

2015年,Kaiming He等人提出的ResNet引入残差模块来解决训练深度神经网络时的网络退化问题,横扫当年的ILSVRC和COCO挑战赛。

2017年,DenseNet模型发布,采用了密集连接结构,使得模型更加紧凑并且有更好的鲁棒性,斩获CVPR 2017最佳论文奖。

以上是CNN发展史上的重要里程碑。在本章,我们将手把手地带大家实现这几个经典论文中的CNN。

1.1 AlexNet

我们首先从最知名的AlexNet开始介绍主流CNN。AlexNet是由Alex Krizhevsky、Ilya Sutskever和Geoffrey Hinton等人共同设计的。到目前为止,这项工作的论文已被引用超过12万次,其创新性和影响力深深地推动了深度学习的发展,至今仍对该领域有着深远的启示。

1.1.1 AlexNet简介

AlexNet模型的设计灵感源于LeNet,但其规模远超LeNet,由5个卷积层和3个全连接层组成。它引入了一些新的技术,比如使用ReLU激活函数替代Sigmoid激活函数,以及利用Dropout进行正则化以防止过拟合等。AlexNet还是第一个在大规模数据集上训练的CNN模型,它在ImageNet数据集上获得了优异的成绩,并在计算机视觉领域引发了广泛关注。

AlexNet模型结构如图1-2所示,输入数据是224×224的三维图像,模型是一个8层的CNN,其中包括5个卷积层和3个全连接层。

图1-2 AlexNet模型结构示意

第一层是卷积层,用于提取图像的特征。它包括96个步长为4的11×11卷积核,并使用最大池化。

第二层是卷积层,包括256个5×5的卷积核,并使用最大池化。

第三、四、五层也是卷积层,分别包括384个3×3的卷积核、384个3×3的卷积核和256个3×3的卷积核。这三层之后使用最大池化。

第六层是全连接层,包括4096个神经元。

第七层是全连接层,包括4096个神经元。

第八层是输出层,包括1000个神经元,用于预测图像在ImageNet数据集中的类别。

小 白:AlexNet看起来就是一个深层CNN,没什么其他特点,为什么要放到第一个讲呢?

梗老师:AlexNet本身的网络结构现在看起来确实平淡无奇,这是因为它出现得早,后续的Inception、ResNet以及DenseNet等都是受到它的启发而出现的,所以我们先来学习它。

1.1.2 代码实现

了解了AlexNet模型结构设计之后,接下来看一下如何用代码实现相关网络结构,这样会更直观。先导入torch、torch.nn和《破解深度学习(基础篇):模型算法与实现》中用过的torchinfo。

# 导入必要的库,torchinfo用于查看模型结构
import torch
import torch.nn as nn
from torchinfo import summary

下面进行结构定义。

首先用nn.Sequential()实现前面5个卷积层并定义为features。这里整体结构不变,但对卷积核略作调整。第一层是11×11的卷积层,后接ReLU+最大池化层。第二层是5×5的卷积层,后接ReLU+最大池化层。第3~5层均为3×3的卷积层,后接ReLU激活函数。最后接一个最大池化层。至此,前面5个卷积层就定义完成了。

然后是3个全连接层classifer,其实就是两个Dropout +全连接+ ReLU的组合,最后的全连接层降维到指定类别数。

最后定义forward()函数。先经过定义好的features卷积层,然后调用flatten()函数将每个样本张量展平为一维,再经过classifier的全连接层即可输出。

至此,我们实现了AlexNet的核心结构,对此还有些困惑的读者可以再看看AlexNet模型结构图,多多思考其设计思路。当然这里实际代码实现中的卷积层参数是经过优化的,与原始论文以及配图中的参数设置可能略有差异,读者着重了解其设计思路和实现技巧即可,后续不再单独说明。

# 定义AlexNet的网络结构
class AlexNet(nn.Module):
    def __init__(self, num_classes=1000, dropout=0.5):
        super().__init__()
        # 定义卷积层
        self.features = nn.Sequential(
            # 卷积+ReLU+最大池化
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            # 卷积+ReLU+最大池化
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            # 卷积+ReLU
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            # 卷积+ReLU
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            # 卷积+ReLU
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            # 最大池化
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        # 定义全连接层
        self.classifier = nn.Sequential(
            # Dropout+全连接层+ReLU
            nn.Dropout(p=dropout),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            # Dropout+全连接层+ReLU
            nn.Dropout(p=dropout),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            # 全连接层
            nn.Linear(4096, num_classes),
        )

    # 定义前向传播函数
    def forward(self, x):
        # 先经过features提取特征,flatten()后送入全连接层
        x = self.features(x)
        x = torch.flatten(x, 1)
        x = self.classifier(x)
        return x

接下来查看网络结构。调用torchinfo.summary()可以看一下刚刚实现的AlexNet模型信息。需要稍微注意的是,使用summary()方法时传入的input_size参数表示示例中输入数据的维度信息,这要与模型可接收的输入数据吻合。

# 查看模型结构及参数量,input_size表示示例输入数据的维度信息
summary(AlexNet(), input_size=(1, 3, 224, 224))
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
AlexNet                                  [1, 1000]                 --
├─Sequential: 1-1                       [1, 256, 6, 6]            --
│    └─Conv2d: 2-1                     [1, 64, 55, 55]           23,296
│    └─ReLU: 2-2                       [1, 64, 55, 55]           --
│    └─MaxPool2d: 2-3                  [1, 64, 27, 27]           --
│    └─Conv2d: 2-4                     [1, 192, 27, 27]          307,392
│    └─ReLU: 2-5                       [1, 192, 27, 27]          --
│    └─MaxPool2d: 2-6                  [1, 192, 13, 13]          --
│    └─Conv2d: 2-7                     [1, 384, 13, 13]          663,936
│    └─ReLU: 2-8                       [1, 384, 13, 13]          --
│    └─Conv2d: 2-9                     [1, 256, 13, 13]          884,992
│    └─ReLU: 2-10                      [1, 256, 13, 13]          --
│    └─Conv2d: 2-11                    [1, 256, 13, 13]          590,080
│    └─ReLU: 2-12                      [1, 256, 13, 13]          --
│    └─MaxPool2d: 2-13                 [1, 256, 6, 6]            --
├─Sequential: 1-2                       [1, 1000]                 --
│    └─Dropout: 2-14                   [1, 9216]                 --
│    └─Linear: 2-15                    [1, 4096]                 37,752,832
│    └─ReLU: 2-16                      [1, 4096]                 --
│    └─Dropout: 2-17                   [1, 4096]                 --
│    └─Linear: 2-18                    [1, 4096]                 16,781,312
│    └─ReLU: 2-19                      [1, 4096]                 --
│    └─Linear: 2-20                    [1, 1000]                 4,097,000
==========================================================================================
Total params: 61,100,840
Trainable params: 61,100,840
Non-trainable params: 0
Total mult-adds (M): 714.68
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 3.95
Params size (MB): 244.40
Estimated Total Size (MB): 248.96
==========================================================================================

当然我们也可以直接使用torchvision.models中集成的AlexNet模型。对比一下输出的模型结构,可以看到和前面手动实现的网络是一致的,参数量也完全一样,用这种方式能更简单地使用AlexNet模型。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.alexnet(), input_size=(1, 3, 224, 224))
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
AlexNet                                  [1, 1000]                 --
├─Sequential: 1-1                       [1, 256, 6, 6]            --
│    └─Conv2d: 2-1                     [1, 64, 55, 55]           23,296
│    └─ReLU: 2-2                       [1, 64, 55, 55]           --
│    └─MaxPool2d: 2-3                  [1, 64, 27, 27]           --
│    └─Conv2d: 2-4                     [1, 192, 27, 27]          307,392
│    └─ReLU: 2-5                       [1, 192, 27, 27]          --
│    └─MaxPool2d: 2-6                  [1, 192, 13, 13]          --
│    └─Conv2d: 2-7                     [1, 384, 13, 13]          663,936
│    └─ReLU: 2-8                       [1, 384, 13, 13]          --
│    └─Conv2d: 2-9                     [1, 256, 13, 13]          884,992
│    └─ReLU: 2-10                      [1, 256, 13, 13]          --
│    └─Conv2d: 2-11                    [1, 256, 13, 13]          590,080
│    └─ReLU: 2-12                      [1, 256, 13, 13]          --
│    └─MaxPool2d: 2-13                 [1, 256, 6, 6]            --
├─AdaptiveAvgPool2d: 1-2                [1, 256, 6, 6]            --
├─Sequential: 1-3                       [1, 1000]                 --
│    └─Dropout: 2-14                   [1, 9216]                 --
│    └─Linear: 2-15                    [1, 4096]                 37,752,832
│    └─ReLU: 2-16                      [1, 4096]                 --
│    └─Dropout: 2-17                   [1, 4096]                 --
│    └─Linear: 2-18                    [1, 4096]                 16,781,312
│    └─ReLU: 2-19                      [1, 4096]                 --
│    └─Linear: 2-20                    [1, 1000]                 4,097,000
==========================================================================================
Total params: 61,100,840
Trainable params: 61,100,840
Non-trainable params: 0
Total mult-adds (M): 714.68
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 3.95
Params size (MB): 244.40
Estimated Total Size (MB): 248.96
==========================================================================================

1.1.3 模型训练

最后看一下模型训练部分,这部分代码与《破解深度学习(基础篇):模型算法与实现》中实现LeNet所使用的代码基本一致,只做了几处较小的改动,下面着重对改动的部分进行讲解。

首先增加了一项设备检测的代码,若能检测到CUDA设备则在GPU上加速运行,若未检测到则在CPU上运行。然后定义模型,这里替换为刚刚实现的AlexNet模型,至于类别数为什么设置为102,稍后再讲。后面的to(device)是指将模型加载到对应的计算设备上。学习率也适当调整了一下,损失函数不变,还是交叉熵。

# 导入必要的库
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models
from tqdm import *
import numpy as np
import sys

# 设备检测,若未检测到cuda设备则在CPU上运行
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 设置随机数种子
torch.manual_seed(0)

# 定义模型、优化器、损失函数
model = AlexNet(num_classes=102).to(device)
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
criterion = nn.CrossEntropyLoss()

接下来着重讲一下数据加载部分,这里的改动比较大。先解释为什么换了一套数据集,前面讲到AlexNet的输入是224 × 224的三维图像,那么之前使用的MNIST手写数据集就不再适用了。

为了便于复现,我们使用torchvision.datasets中自带的Flowers102数据集。从名字上能看出来,这是一个包含102个类别的花朵数据集,每个类别由40~258张图像组成。其实简单来说,Flowers102数据集与手写数据集的差异主要在于,这是一套三通道彩色图像数据。同时图片分辨率变大,类别数也从10变成了102,这就是前面类别数设置为102的原因。关于更加详细的介绍,读者可以自行查看torchvision的官方文档里或者数据集的介绍页面。

关于数据集还有两个地方需要注意。

Flowers102用于区分训练集和测试集的参数不再是MNIST数据集的train参数,而是split参数,细心的读者可能已经发现我们指定的split参数值好像反了,这是因为Flowers102数据集中测试集的数据量比训练集多,所以为简单起见,这里直接将测试集和训练集数据对调使用。大家第一次运行的时候会自动下载数据,耐心等待即可。

DataLoader中的num_workers参数表示并行加载数据的子进程数。如果你发现这部分在运行的时候报错,可以适当减小该参数的数值或直接设置为0即可。

讲解完数据集,再来看一下数据变换的部分,相比MNIST数据集,这部分也复杂了不少。首先是区分了训练集和测试集所使用的不同数据变换,有别于手写数据集中规整的数据,花朵图片的不同形态会影响其分类效果,所以对于训练集进行简单的数据增强,包括随机旋转;以随机比例裁剪并resize,这里直接指定为224即可;随机水平方向和竖直方向的翻转;转换为张量后进行归一化。需要注意,这里是对三通道的数据进行归一化,指定其均值和标准差。这三组数值通过从ImageNet数据集上的百万张图片中随机抽样计算得到,是一套适用于预训练模型的魔法常数,但久而久之大家也就都这么用了,大家了解即可。测试集的数据变换就不需要进行数据增强了,仅使用resize和归一化。最后将这里定义的方法传入Flowers102。

到这里,新数据集部分就介绍完了。建议还有疑惑的读者对照代码多看几遍。本书后面的几个代码实现中还会反复用到Flowers102数据集。如果该数据集报错,可以尝试升级torchvision的版本。

# 设置训练集的数据变换,进行数据增强
trainform_train = transforms.Compose([
    transforms.RandomRotation(30), # 随机旋转 -30度和30度之间
    transforms.RandomResizedCrop((224, 224)), # 随机比例裁剪并进行resize
    transforms.RandomHorizontalFlip(p = 0.5), # 随机水平翻转
    transforms.RandomVerticalFlip(p = 0.5), # 随机垂直翻转
    transforms.ToTensor(),  # 将数据转换为张量
    # 对三通道数据进行归一化(均值,标准差),数值是从ImageNet数据集上的百万张图片中随机抽样计算得到
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 设置测试集的数据变换,不进行数据增强,仅使用resize和归一化
transform_test = transforms.Compose([
    transforms.Resize((224, 224)),  # resize
    transforms.ToTensor(),  # 将数据转换为张量
    # 对三通道数据进行归一化(均值,标准差),数值是从ImageNet数据集上的百万张图片中随机抽样计算得到
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 加载训练数据,需要特别注意的是,Flowers102数据集中test簇的数据量较多,所以这里使用"test"作为训练集
train_dataset = datasets.Flowers102(root='../data/flowers102', split="test", 
                                    download=True, transform=trainform_train)

# 实例化训练数据加载器
train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True, num_workers=6)

# 加载测试数据,使用"train"作为测试集
test_dataset = datasets.Flowers102(root='../data/flowers102', split="train", 
                                   download=True, transform=transform_test)

# 实例化测试数据加载器
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=6)

下面我们需要设置好epoch数,还需要将数据也加载到指定的计算设备上。对于训练集和测试集都需要进行上述操作,和模型部分一样加个to(device)即可。其他内容都和LeNet部分的代码完全一样。最后输出的损失和准确率曲线如图1-3所示。500个epoch之后,测试集上的准确率约为65.8%,大家可以自行调整参数进行实验。

图1-3 损失和准确率曲线

# 设置epoch数并开始训练
num_epochs = 500  # 设置epoch数
loss_history = []  # 创建损失历史记录列表
acc_history = []   # 创建准确率历史记录列表

# tqdm用于显示进度条并评估任务时间开销
for epoch in tqdm(range(num_epochs), file=sys.stdout):
    # 记录损失和预测正确数
    total_loss = 0
    total_correct = 0
    
    # 批量训练
    model.train()
    for inputs, labels in train_loader:

        # 将数据转移到指定计算资源设备上
        inputs = inputs.to(device)
        labels = labels.to(device)
        
        # 预测、损失函数、反向传播
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        
        # 记录训练集loss
        total_loss += loss.item()
    
    # 测试模型,不计算梯度
    model.eval()
    with torch.no_grad():
        for inputs, labels in test_loader:
            # 将数据转移到指定计算资源设备上
            inputs = inputs.to(device)
            labels = labels.to(device)
            
            # 预测
            outputs = model(inputs)
            # 记录测试集预测正确数
            total_correct += (outputs.argmax(1) == labels).sum().item()
        
    # 记录训练集损失和测试集准确率
    loss_history.append(np.log10(total_loss))  # 将损失加入损失历史记录列表,由于数值有时较大,这里取对数
    acc_history.append(total_correct / len(test_dataset))# 将准确率加入准确率历史记录列表
    
    # 打印中间值
    if epoch % 50 == 0:
        tqdm.write("Epoch: {0} Loss: {1} Acc: {2}".format(epoch, loss_history[-1], acc_history[-1]))

# 使用Matplotlib绘制损失和准确率的曲线图
import matplotlib.pyplot as plt
plt.plot(loss_history, label='loss')
plt.plot(acc_history, label='accuracy')
plt.legend()
plt.show()

# 输出准确率
print("Accuracy:", acc_history[-1])
Epoch: 0 Loss: 2.056710654079528 Acc: 0.00980392156862745
Epoch: 50 Loss: 1.81992189286025 Acc: 0.2568627450980392
Epoch: 100 Loss: 1.7028522357427933 Acc: 0.40588235294117647
Epoch: 150 Loss: 1.655522045895868 Acc: 0.3480392156862745
Epoch: 200 Loss: 1.4933272565532425 Acc: 0.5362745098039216
Epoch: 250 Loss: 1.4933084530330363 Acc: 0.592156862745098
Epoch: 300 Loss: 1.3643970408766197 Acc: 0.6303921568627451
Epoch: 350 Loss: 1.349363543060287 Acc: 0.6107843137254902
Epoch: 400 Loss: 1.3387303540492963 Acc: 0.6411764705882353
Epoch: 450 Loss: 1.2464284899548854 Acc: 0.6696078431372549
100%|██████████| 500/500 [2:41:21<00:00, 19.36s/it]

1.1.4 小结

在本节中,我们首先回顾一些经典CNN,随后着重介绍了AlexNet的特点、网络结构及代码实现。从模型定义、网络结构实现以及与torchvision中集成的模型进行对比,让大家对它有了全面深入的理解。同时,我们引入了一套新的实验数据集Flowers102,并基于这套数据集训练,展示了模型测试效果。

1.2 VGGNet

2012年提出的AlexNet模型奠定了深度学习在计算机视觉领域中的地位。而到2014年,VGGNet凭借其更深更宽的网络结构获得了ILSVRC(ImageNet Large Scale Visual Recognition Competition)
定位任务的第一名和分类任务的第二名。接下来,我们将详细介绍VGGNet,看看它有哪些创新点,并对其进行简单的代码实现。

1.2.1 VGGNet简介

VGGNet论文的作者Karen Simonyan和Andrew Zisserman当时都是牛津大学工程科学系Visual Geometry Group成员,这也是“VGG”名字的由来。从原始论文摘要中能看出关键研究主要集中在CNN深度对模型性能的影响。它使用了3×3的小卷积核,并将网络深度提升到了11~19层。基于这种设计,VGGNet在其他数据集上也取得了前所未有的成果,这意味着它具有极强的泛化能力和扩展性。

VGGNet的模型结构非常简洁,如图1-4所示,整个网络使用了小尺寸的卷积核以及相同的池化尺寸(2×2)。到目前为止,VGGNet依然广泛应用于计算机视觉领域的各类任务,常被用来提取图像特征,相关论文引用次数已经超过9万。

图1-4 VGGNet的模型结构示意

VGGNet的主要思想其实在于解决了平衡问题。在当时的CNN模型中,网络深度越深,表现就越好,但同时也会带来较高的计算复杂度、较大的模型以及过拟合现象。VGGNet通过设计一种更深但同时较小的CNN而解决了这一问题,提高了模型的泛化能力,在保证较高精度的同时,兼顾了模型的计算效率。

VGGNet模型结构如表1-1所示,这是一个典型的CNN模型,由若干卷积层和全连接层组成。从中我们可以看到,VGGNet包含多种级别的网络,网络深度从11~19层不等,但每个网络均由三个部分组成。

表1-1 VGGNet模型结构

ConvNet Configuration

A

A-LRN

B

C

D

E

11权重层

11权重层

13权重层

16权重层

16权重层

19权重层

输入(224×224 RGB图像)

conv3-64

conv3-64 LRN

conv3-64

conv3-64

conv3-64

conv3-64

conv3-64

conv3-64

conv3-64

conv3-64

最大池化

conv3-128

conv3-128

conv3-128

conv3-128

conv3-128

conv3-128

conv3-128

conv3-128

conv3-128

conv3-128

最大池化

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

conv1-256

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

conv3-256

最大池化

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv1-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

最大池化

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv1-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

conv3-512

最大池化

FC-4096

FC-4096

FC-1000

Softmax

卷积层:VGGNet卷积层的卷积核大小均为3×3,表1-1中的conv3-256表示的就是3×3的卷积核、256个通道。

池化层:在卷积层之后使用了最大池化层来缩小图像尺寸,通过池化层的下采样来减小计算复杂度。

全连接层:包括两个4096维的全连接层和一个输出层,输出层的大小取决于任务的类别数。最后经过一个Sofmax得到最终类别上的概率分布。

只要明白了上述三部分,VGGNet的精髓就不难掌握了。

表1-1中还有以下两点需要特别注意。

在A网络后面有一个A-LRN网络,所谓LRN其实是局部响应归一化(local response normalization,LRN),但在VGGNet论文中通过对比后发现,LRN对网络性能的提升没有帮助,现在很少有人使用了,所以大家只要了解有这样一个结构即可,后续代码实现中也会略过这个部分。

C网络和D网络都是16层网络,区别在于,C网络其实是在B网络的基础上增加了3个1×1的卷积层。经实验发现1×1的卷积核增加了额外的非线性提升效果,而D网络用3×3卷积层替换1×1卷积层之后实现了更好的效果,所以我们所说的VGG16模型一般是指这个D网络模型,后面的代码实现是A、B、D、E这四个网络,一般将它们分别称为VGG11、VGG13、VGG16、VGG19。

那么为什么要选用小卷积核,或者说它有什么优势呢?

VGGNet中使用的都是3×3的小卷积核。本质上讲,两个3×3卷积层串联相当于1个5×5的卷积层,如图1-5所示。而如果是3个3×3的卷积层串联,就相当于1个7×7的卷积层,即3个3×3卷积层的感受野大小相当于1个7×7的卷积层。但是3个3×3卷积层的参数量只有7×7卷积层的一半左右,同时前者可以有3个非线性操作,而后者只有1个非线性操作,这使得多个小卷积层对于特征的学习能力更强,同时参数更少。

图1-5 感受野效果示意

梗老师:VGGNet相对于AlexNet有什么不同之处?

小 白:VGGNet继承了AlexNet的思想,但采用了更深的网络结构,具有更多的卷积层,从而进一步提高了对于特征学习的能力。

梗老师:没错,也因为VGGNet相对较深,需要大量的参数和计算资源,因此不太适合轻量级应用或资源受限的环境。

1.2.2 代码实现

了解了VGGNet的基本思想和结构设计之后,我们来看一下如何用代码实现相关网络结构。

# 导入必要的库,torchinfo用于查看模型结构
import torch
import torch.nn as nn
from torchinfo import summary

下面用代码定义网络结构,开始实现VGGNet,模式和之前AlexNet基本一致。其中features是卷积层提取特征的网络结构,为了适配多种级别的网络,其需要单独生成,后面会专门讲解构建这部分的内容。classifier是最后的全连接层生成分类的网络结构,其中包含三个全连接层,当然也可以看成两个全连接层+ ReLU + Dropout层的组合,最后一个全连接层降维。

forward()函数定义前向传播过程,描述各层间的连接关系,经过features卷积层提取特征,然后调用flatten()函数将每个样本张量展平为一维,最后classifier进行分类输出。到这里其基本结构就定义完成了,对结构还有些困惑的读者可以再看看表1-1所示的VGGNet模型结构表,多多思考其设计思路。

# 定义VGGNet的网络结构
class VGG(nn.Module):
    def __init__(self, features, num_classes=1000):
        super(VGG, self).__init__()
        # 卷积层直接使用传入的结构,后面有专门构建这部分的内容
        self.features = features
        # 定义全连接层
        self.classifier = nn.Sequential(
            # 全连接层+ReLU+Dropout
            nn.Linear(512 * 7 * 7, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            # 全连接层+ReLU+Dropout
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            # 全连接层
            nn.Linear(4096, num_classes),
        )

    # 定义前向传播函数
    def forward(self, x):
        # 先经过feature提取特征,flatten()后送入全连接层
        x = self.features(x)
        x = torch.flatten(x, 1)
        x = self.classifier(x)
        return x

接下来要做的是定义相关配置项。由于VGGNet包含多种级别的网络,因此需要定义一个配置项进行区分,其中每个key都代表一个模型的配置文件,前面提到过这里主要包括VGG11、VGG13、VGG16、VGG19四种级别。配置项里面的数字代表卷积层中卷积核的个数,'M'表示最大池化层。其中的数值和表1-1所示的VGGNet模型结构表中的完全一致,大家可以自行对照一下。

# 定义相关配置项,其中M表示最大池化层,数值完全对应论文中的表格数值
cfgs = {
    'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
    'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 
512, 'M'],
    'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 
'M', 512, 512, 512, 'M'],
    'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 
512, 512, 'M', 512, 512, 512, 512, 'M'],
}

有了配置项,我们就可以根据对应配置拼接卷积层了。

定义一个拼接卷积层的函数,传入的参数就是前面定义的某个配置项,通过遍历传入的配置列表拼接出对应的网络结构。当遍历到字母M时,拼接上一个最大池化层;当遍历到数字时,其对应卷积核的个数,拼接一个卷积层和一个ReLU层,之后记录其对应的卷积核个数的数值,所以一开始in_channels设为3,后面逐层记录作为下一次的in_channels。遍历完成后,最后调用nn.Sequential()将构造出的卷积层返回即可。

通过这种方式,我们就不用逐层地手动定义了,借助一个循环就完成了。

# 根据传入的配置项拼接卷积层
def make_layers(cfg):
    layers = []
    in_channels = 3 #初始通道数为3
    # 遍历传入的配置项
    for v in cfg:
        if v == 'M': # 如果是池化层,则直接新增MaxPool2d即可
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
        else: # 如果是卷积层,则新增3×3卷积+ReLU
            conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
            layers += [conv2d, nn.ReLU(inplace=True)]
            in_channels = v #记录通道数,作为下一次的in_channels
    # 返回使用Sequential构造的卷积层
    return nn.Sequential(*layers)

然后封装对应模型的函数,将配置项传入make_layers()函数拼接出卷积层,再将其作为VGGNet中的features层,就可以直接被调用了。这里的num_classes是指类别数,默认设为1000。

# 封装函数,依次传入对应的配置项
def vgg11(num_classes=1000):
    return VGG(make_layers(cfgs['vgg11']), num_classes=num_classes)

def vgg13(num_classes=1000):
    return VGG(make_layers(cfgs['vgg13']), num_classes=num_classes)

def vgg16(num_classes=1000):
    return VGG(make_layers(cfgs['vgg16']), num_classes=num_classes)

def vgg19(num_classes=1000):
    return VGG(make_layers(cfgs['vgg19']), num_classes=num_classes)

接下来看一下网络结构,以较为常用的VGG16为例,调用torchinfo.summary()可以查看刚刚实现的模型信息,包括前面的卷积层以及后面的全连接层,并计算其参数量。可以看到,该模型包含将近一亿四千万个参数,当然大部分参数量还是在全连接层部分。

# 查看模型结构及参数量,input_size表示示例输入数据的维度信息
summary(vgg16(), input_size=(1, 3, 224, 224))

与torchvision.models中集成的VGGNet进行对比,可以看到和前面手动实现的网络是一致的,参数量也完全一样,用这种方式能更简单地使用VGGNet。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.vgg16(), input_size=(1, 3, 224, 224))

1.2.3 模型训练

最后看一下模型训练部分,这部分代码与前面实现AlexNet所使用的代码基本一致,只做了几处很小的改动。下面我们还是着重对改动的部分进行讲解,对于没有改动的部分就不作赘述了,建议不太清楚的读者回顾前面的章节。

首先还是模型定义部分,这里替换成刚刚实现的vgg11模型,优化器和损失函数都不变。

# 定义模型、优化器、损失函数
model = vgg11(num_classes=102).to(device)
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
criterion = nn.CrossEntropyLoss()

对于数据集还是使用Flowers102数据集,batch_size适当调小为64,epoch数调为200,大家也可以根据自己的实际情况自行调整。

# 加载训练数据,需要特别注意的是,Flowers102数据集中test簇的数据量较多,所以这里使用"test"作为训练集
train_dataset = datasets.Flowers102(root='../data/flowers102', split="test", 
download=True, transform=trainform_train)
# 实例化训练数据加载器
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4)
# 加载测试数据,使用"train"作为测试集
test_dataset = datasets.Flowers102(root='../data/flowers102', split="train", 
download=True, transform=transform_test)
# 实例化测试数据加载器
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=4)

# 设置epoch数并开始训练
num_epochs = 200  # 设置epoch数

其他部分都没有变。最后看一下输出的损失和准确率曲线,如图1-6所示,200个epoch之后,测试集上的准确率约为71.8%,对比前面的AlexNet实现了一定提升,大家可以自行调整参数进行实验。

# 其他部分与AlexNet代码一致
# ...
100%|██████████| 200/200 [1:42:13<00:00, 30.67s/it]

图1-6 损失和准确率曲线

1.2.4 小结

本节详细讨论了VGGNet的特性和模型结构,提供了相关的代码实现,并将其与torchvision库中集成的模型进行了对比。我们还探讨了使用小卷积核的原因及其相关优点。此外,我们还在Flowers102数据集上训练了模型并测试了效果。

1.3 批归一化方法

前面讲了两个在CNN发展过程中有着重要地位的模型,在本节中,我们换一种角度,从结构上优化模型,也就是下面要讲的批归一化。

1.3.1 批归一化简介

批归一化(batch normalization,BN)是深度学习中常用的一种技术,它实际上是一种数据标准化方法。通过对每批输入数据进行标准化,可以提升神经网络的训练速度和精度。通过这种方法计算数据的均值和方差,将数据进行缩放和平移,使其分布在指定的范围内,有助于缓解深度神经网络中梯度消失和梯度爆炸的问题,通常被插入卷积层或全连接层的输入或输出之间,有助于模型的收敛。BN处理示意如图1-7所示。

图1-7 BN处理示意

BN是由谷歌的两位工程师Sergey Ioffe和Christian Szegedy在2015年共同提出的。截至目前,其论文已经被引用超过4万次。

这个BN到底是如何计算的呢?下面是论文给出的计算公式。

上述公式看起来好像很复杂,其实很简单。公式经过一定变形并推导后,输出数据y的每个元素通过下面的公式计算即可。

其中,x表示输入数据,分别表示输入数据的均值和方差,表示BN层的两个可训练参数, 是一个很小的常数,用于避免方差为0的情况。这么看起来是不是就好理解多了?通过上述变换可以有效减小数据的发散程度,从而降低学习难度。

BN在实现时一般还有两个参数,即移动均值和方差,用于描述整个数据集的情况,这里就不做详细介绍了,感兴趣的读者可以自行查阅相关资料。

那么BN为什么有效呢?主要包括下面几个原因。

通过对输入和中间网络层的输出进行标准化处理,减少了内部神经元分布的改变,降低了不同样本值域的差异性,使得大部分数据处在非饱和区域,保证了梯度能够很好地回传,缓解了梯度消失和梯度爆炸。

通过减少梯度对参数或其初始值尺度的依赖,使得可以用较大的学习率对网络进行训练,从而加快了收敛速度。

BN本质上是一种正则化手段,能够提升网络的泛化能力,可以减少或者去除Dropout机制,从而优化网络结构。

小 白:如果不进行BN,会有什么问题?

梗老师:问题太多了,比如训练效果可能会不稳定,甚至出现梯度消失和梯度爆炸。不过,如果模型本身能正常收敛的话,收敛速度往往要比经BN的快。

1.3.2 代码实现

了解BN的基本思想之后,下面我们来看如何用代码实现。

# 导入必要的库,torchinfo用于查看模型结构
import torch
import torch.nn as nn
from torchinfo import summary

接下来我们以《破解深度学习(基础篇):模型算法与实现》中的LeNet模型为例,对其用BN层进行改造。PyTorch框架提供了相关方法,直接使用就行。这部分改动量不大,只需要在卷积层和前两个全连接层后面都加上一个BatchNorm层,传入对应的通道数即可。

需要特别注意的是输入维度略有不同,卷积层后面用的是BatchNorm2d,而全连接层后面用的是BatchNorm1d。再下面的forward()函数不变,在卷积层后面接最大池化层,激活函数都是ReLU,依次处理后输出。

# 定义LeNet的网络结构
class LeNet(nn.Module):
    def __init__(self):
        super(LeNet, self).__init__()
        # 卷积层1:输入1个通道,输出6个通道,卷积核大小为5×5,后接BN
        self.conv1 = nn.Sequential(
            nn.Conv2d(1, 6, 5),
            nn.BatchNorm2d(6)
        )
        # 卷积层2:输入6个通道,输出16个通道,卷积核大小为5×5,后接BN
        self.conv2 = nn.Sequential(
            nn.Conv2d(6, 16, 5),
            nn.BatchNorm2d(16)
        )
        # 全连接层1:输入16×4×4=256个节点,输出120个节点,由于输入数据略有差异,修改为16×4×4
        self.fc1 = nn.Sequential(
            nn.Linear(16 * 4 * 4, 120),
            nn.BatchNorm1d(120)
        )
        # 全连接层2:输入120个节点,输出84个节点
        self.fc2 = nn.Sequential(
            nn.Linear(120, 84),
            nn.BatchNorm1d(84)
        )
        # 输出层:输入84个节点,输出10个节点
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        # 使用ReLU激活函数,并进行最大池化
        x = torch.relu(self.conv1(x))
        x = nn.functional.max_pool2d(x, 2)
        # 使用ReLU激活函数,并进行最大池化
        x = torch.relu(self.conv2(x))
        x = nn.functional.max_pool2d(x, 2)
        # 将多维张量展平为一维张量
        x = x.view(-1, 16 * 4 * 4)
        # 全连接层
        x = torch.relu(self.fc1(x))
        # 全连接层
        x = torch.relu(self.fc2(x))
        # 全连接层
        x = self.fc3(x)
        return x

再往下来看网络结构。调用torchinfo.summary()可以查看刚刚改造后的模型信息。新增的与池化层不同,BatchNorm层有可训练参数,因此改造后的参数量也会有变化。

# 查看模型结构及参数量,input_size表示示例输入数据的维度信息
summary(LeNet(), input_size=(1, 1, 28, 28))
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
LeNet                                    [1, 10]                   --
├─Sequential: 1-1                       [1, 6, 24, 24]            --
│    └─Conv2d: 2-1                     [1, 6, 24, 24]            156
│    └─BatchNorm2d: 2-2                [1, 6, 24, 24]            12
├─Sequential: 1-2                       [1, 16, 8, 8]             --
│    └─Conv2d: 2-3                     [1, 16, 8, 8]             2,416
│    └─BatchNorm2d: 2-4                [1, 16, 8, 8]             32
├─Sequential: 1-3                       [1, 120]                  --
│    └─Linear: 2-5                     [1, 120]                  30,840
│    └─BatchNorm1d: 2-6                [1, 120]                  240
├─Sequential: 1-4                       [1, 84]                   --
│    └─Linear: 2-7                     [1, 84]                   10,164
│    └─BatchNorm1d: 2-8                [1, 84]                   168
├─Linear: 1-5                           [1, 10]                   850
==========================================================================================
Total params: 44,878
Trainable params: 44,878
Non-trainable params: 0
Total mult-adds (M): 0.29
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.08
Params size (MB): 0.18
Estimated Total Size (MB): 0.26
==========================================================================================

1.3.3 模型训练

最后替换LeNet模型结构重新进行训练,代码和《破解深度学习(基础篇):模型算法与实现》中讲过的LeNet模型训练部分完全一致。损失和准确率曲线如图1-8所示,可以看出,在增加了BN层后,准确率较原始的LeNet又有一定提升,而且收敛得更快了。

# 代码部分与前面章节的LeNet代码一致
# ...
Epoch: 0 Loss: 2.2088656986293165 Acc: 0.9578
Epoch: 2 Loss: 1.4376559603001913 Acc: 0.979  
Epoch: 4 Loss: 1.228520721191793 Acc: 0.9803  
Epoch: 6 Loss: 1.106042682456222 Acc: 0.9859  
Epoch: 8 Loss: 1.0158490855052476 Acc: 0.9883 
100%|██████████| 10/10 [01:56<00:00, 11.62s/it]

图1-8 损失和准确率曲线

1.3.4 小结

本节重点讲解了BN的特性及其计算方式,探讨了这种方法为什么能有效缓解梯度消失和梯度爆炸并加快收敛速度。最后,我们对LeNet模型进行了改造,并以MNIST数据集为例,对比了有无BN层情况下的训练效果。

1.4 GoogLeNet

GoogLeNet是由谷歌研究团队Christian Szegedy等人在2014年ImageNet竞赛中提出的深度网络结构。之所以采用“GoogLeNet”而非“GoogleNet”的命名方式,据说是为了向LeNet表达敬意。GoogLeNet在当年竞赛中获得了最高的分类精度,与之前介绍过的VGGNet分列比赛的第一名和第二名,而两个模型之间的差距也在毫厘之间。GoogLeNet的设计受到了LeNet和AlexNet的启发,其在模型结构方面有了很大的改进。

1.4.1 GoogLeNet简介

在GoogLeNet之前,类似AlexNet、VGGNet这样的模型主要是通过增加网络深度来提升训练效果的。然而,这种策略可能会引发一系列问题,如过拟合、梯度消失和梯度爆炸等。GoogLeNet提出了Inception结构,从另一种角度来优化训练结果。它可以更有效地利用计算资源,在保持相同计算量的情况下增加网络宽度和深度,提取更丰富的特征,从而优化训练结果。截至目前,该论文的引用次数已经超过4万次。接下来我们就详细介绍GoogLeNet到底有哪些创新点,并给出对应的代码实现。

1.4.2 Inception结构

先来看看GoogLeNet模型的重要组成部分:Inception结构。典型的深度学习图像分类网络存在收敛速度慢、训练参数多、训练时间长、容易发生梯度消失和梯度爆炸等问题。为了应对这些挑战,Inception结构应运而生(见图1-9),它融合了不同尺度的特征信息,是一种带有稀疏性的高性能网络结构。

图1-9 Inception结构示意

具体来说,它将1×1、3×3、5×5的卷积层和3×3的最大池化层堆叠起来,在3×3和5×5的卷积层之前以及3×3最大池化层之后加上了1×1的卷积层用于降维。它通过增加网络宽度融合不同小尺度的卷积与池化操作,能够有效地捕获图像中不同尺度的特征,实现更好识别效果的同时,有效减少模型参数。这些优点使得Inception结构在很多图像处理任务中表现出色。

很多人刚开始不太理解为什么要用1×1的卷积核。其实这样做的目的是减少输入张量的通道数,从而减少模型参数量。例如,如果输入是一个256×256×128的张量,而1×1卷积核的个数是64,那么使用该卷积核后的输出就是一个256×256×64的张量。可以看到,张量通道数减少了一半,也就意味着模型参数量减少了一半。

在后续的发展中,Inception结构还产生了各种改进的变体,这里我们就不再扩展了。感兴趣的话大家可以自行查阅相关资料。

1.4.3 GoogLeNet的模型结构

下面我们来看GoogLeNet的模型结构,如图1-10所示。很多读者一看到这个结构就很困惑。其实不用着急,其中很多元素都是相同的Inception结构,静下心来梳理一下并不难理解。对应区域我们都用红框标记了。

第一部分是卷积层,包含64个步长为2的7×7卷积核,后接最大池化层。

第二部分也是卷积层,包含64个1×1卷积核,然后是192个步长为1的3×3卷积核,后接最大池化层。

第三部分是2个Inception层,后接最大池化层。

第四部分是5个Inception层,后接最大池化层。

第五部分是2个Inception层,后接平均池化层。

第六部分是输出层,包括1000个神经元的全连接,用于预测图像所属的类别。

图1-10 GoogLeNet模型结构示意

这样一梳理,其实只有六个部分,是不是清晰多了?

为了避免梯度消失,上述模型额外增加了两个辅助的Softmax(也就是图1-10中下部的两个“辅助损失”),用于前向传导梯度,也可以将其理解为辅助分类器。它们将中间某层的输出用作分类,并按一个较小的权重加到最终的分类结果中。这样做相当于进行了模型融合,同时给网络增加了反向传播的梯度信号,提供了额外的正则化,对于整个网络的训练大有裨益。在测试的时候,这两个额外的Softmax会被去掉。这部分大家了解即可,为了简化,后面代码实现中也会暂时省略这部分。

小 白:Inception模块通过并行操作多个卷积核和池化层,可以捕获多尺度的特征,这让我想到了多头注意力!

梗老师:虽然Inception结构和多头注意力是两种不同的概念,所处理的任务和数据也不一样,但它们的思想很相近。两个结构都是为了关注多个特征或尺度,增加模型的表示能力。你能发现二者在原理上的相似之处,非常不错哦!

1.4.4 代码实现

了解了GoogLeNet的基本思想和典型结构之后,我们来看如何用代码实现。为了便于大家理解,我们略作简化,将重点放在主要思路上。

# 导入必要的库
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchinfo import summary

先定义一个基础卷积结构BasicConv2d,它其实是结合BN对卷积层的一个改进,本质上就是在卷积层和激活函数之间加入一个BN层。后续会大量使用这个结构,你也可以注释掉这里的BN层代码来对比训练效果。

# 定义一个基础卷积结构BasicConv2d,改进型
class BasicConv2d(nn.Module):
    def __init__(self, in_channels, out_channels, **kwargs):
        super().__init__()
        # 卷积+BN层
        self.conv = nn.Conv2d(in_channels, out_channels, bias=False, **kwargs)
        self.bn = nn.BatchNorm2d(out_channels, eps=0.001)

    # 定义前向传播函数
    def forward(self, x):
        # 依次经过卷积和BN层,最后激活函数ReLU
        x = self.conv(x)
        x = self.bn(x)
        return F.relu(x, inplace=True)

接下来定义Inception结构,其中包含4个分支路径branch。大家可以对照图1-10,查看构造函数中每个参数的含义。

in_channels:表示上一层输入的通道数。

ch1×1:表示1×1卷积的个数。

ch3×3red:表示3×3卷积之前1×1卷积的个数。

ch3×3:表示3×3卷积的个数。

ch5×5red:表示5×5卷积之前1×1卷积的个数。

ch5×5:表示5×5卷积的个数。

pool_proj:表示池化后1×1卷积的个数。

结合这些参数就可以使用nn.Sequential()定义出4个分支路径的结构。然后用forward()函数定义前向传播过程,经过4个分支路径后拼接组成输出。

# 定义Inception结构
class Inception(nn.Module):

    # in_channels表示上一层输入的通道数,ch1×1表示1×1卷积的个数
    # ch3×3red表示3×3卷积之前1×1卷积的个数,ch3×3表示3×3卷积的个数
 # ch5×5red表示5×5卷积之前1×1卷积的个数,ch5×5表示5×5卷积的个数
 # pool_proj表示池化后1×1卷积的个数
    def __init__(self, in_channels, ch1×1, ch3×3red, ch3×3, ch5×5red, ch5×5, 
pool_proj):
        super().__init__()

        # 定义4个分支路径
        self.branch1 = BasicConv2d(in_channels, ch1×1, kernel_size=1)
        self.branch2 = nn.Sequential(
            BasicConv2d(in_channels, ch3×3red, kernel_size=1),
            BasicConv2d(ch3×3red, ch3×3, kernel_size=3, padding=1)
        )
        self.branch3 = nn.Sequential(
            BasicConv2d(in_channels, ch5×5red, kernel_size=1),
            BasicConv2d(ch5×5red, ch5×5, kernel_size=5, padding=2)
        )
        self.branch4 = nn.Sequential(
            nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
            BasicConv2d(in_channels, pool_proj, kernel_size=1)
        )

    # 定义前向传播函数
    def forward(self, x):
        # 经过4个分支路径
        branch1 = self.branch1(x)
        branch2 = self.branch2(x)
        branch3 = self.branch3(x)
        branch4 = self.branch4(x)

        # 拼接结果后输出
        outputs = [branch1, branch2, branch3, branch4]
        return torch.cat(outputs, dim=1)

然后我们就可以定义GoogLeNet了。每个part分别对应图1-10中梳理过的6个部分。第一部分是卷积层;第二部分包含2个卷积层;第三部分是2个Inception层,需要注意这里的数值分别是维度和卷积数,都是原始论文中提供的;第四部分是5个Inception层;第五部分是2个Inception层;第六部分是Flatten + Dropout +全连接层;最后是forward()函数,依次经过这6个部分输出即可。

# 定义GoogLeNet的网络结构
class GoogLeNet(nn.Module):

    def __init__(self, num_classes=1000):
        super().__init__()
        
        # 第一部分,卷积+最大池化
        self.part1 = nn.Sequential(
            BasicConv2d(3, 64, kernel_size=7, stride=2, padding=3),
            nn.MaxPool2d(3, stride=2, ceil_mode=True)
        )
        # 第二部分,卷积+卷积+最大池化
        self.part2 = nn.Sequential(
            BasicConv2d(64, 64, kernel_size=1),
            BasicConv2d(64, 192, kernel_size=3, padding=1),
            nn.MaxPool2d(3, stride=2, ceil_mode=True)
        )
        # 第三部分,Inception*2 + 最大池化,数值参考论文结构表
        self.part3 = nn.Sequential(
            Inception(192, 64, 96, 128, 16, 32, 32),
            Inception(256, 128, 128, 192, 32, 96, 64),
            nn.MaxPool2d(3, stride=2, ceil_mode=True)
        )
        # 第四部分,Inception*5 + 最大池化,数值参考论文结构表
        self.part4 = nn.Sequential(
            Inception(480, 192, 96, 208, 16, 48, 64),
            Inception(512, 160, 112, 224, 24, 64, 64),
            Inception(512, 128, 128, 256, 24, 64, 64),
            Inception(512, 112, 144, 288, 32, 64, 64),
            Inception(528, 256, 160, 320, 32, 128, 128),
            nn.MaxPool2d(3, stride=2, ceil_mode=True)
        )
        # 第五部分,Inception*2 + 平均池化,数值参考论文结构表
        self.part5 = nn.Sequential(
            Inception(832, 256, 160, 320, 32, 128, 128),
            Inception(832, 384, 192, 384, 48, 128, 128),
            nn.AdaptiveAvgPool2d((1, 1))
        )
        # 第六部分,Flatten+Dropout+全连接
        self.part6 = nn.Sequential(
            nn.Flatten(),
            nn.Dropout(0.4),
            nn.Linear(1024, num_classes)
        )

    # 定义前向传播函数
    def forward(self, x):
        # 依次经过6个部分后输出
        x = self.part1(x)
        x = self.part2(x)
        x = self.part3(x)
        x = self.part4(x)
        x = self.part5(x)
        x = self.part6(x)
        return x

随后我们来看模型结构。调用torchinfo.summary(),查看刚刚实现的GoogLeNet模型信息并计算其参数量。

# 查看模型结构及参数量,input_size表示示例中输入数据的维度信息
summary(GoogLeNet(), input_size=(1, 3, 224, 224))

同样,还有更简便的方法。PyTorch的torchvision.models也集成了GoogLeNet模型,可以直接使用。它输出的模型结构和我们前面手动实现的结构基本一致。用这种方法可以更简单地使用GoogLeNet。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.googlenet(), input_size=(1, 3, 224, 224))

1.4.5 模型训练

最后要做的是模型训练,这部分代码与前面AlexNet的基本一致。下面我们着重对改动部分进行讲解。先是模型定义,这里替换成我们刚刚实现的GoogLeNet模型,优化器和损失函数都不变。如果对这部分不太清楚,建议回顾前面章节的内容。

# 定义模型、优化器、损失函数
model = GoogLeNet(num_classes=102).to(device)
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
criterion = nn.CrossEntropyLoss()

数据集还是Flowers102数据集,batch_size为64,epoch数为200,其他部分都没有变化。最后看一下输出的损失和准确率曲线,如图1-11所示,训练200个epoch之后,测试集上的准确率约为69.5%。大家可以自行调整参数进行实验。

# 其他部分与AlexNet代码一致
# ...
100%|██████████| 200/200 [1:17:11<00:00, 23.16s/it]

图1-11 损失和准确率曲线

1.4.6 小结

在本节中,我们详细介绍了经典的卷积神经网络GoogLeNet,包括其特性以及Inception结构。通过代码实现并与torchvision中集成的模型对比,进一步讨论了1×1卷积核的功能及其优点。最后,我们使用Flowers102数据集进行训练,并提供了模型的测试效果。

1.5 ResNet

在1.4节,我们详细讨论了GoogLeNet的特性、模型结构以及代码实现,接下来我们将转向本章的焦点—被誉为CNN发展史上最经典的结构—ResNet。

1.5.1 ResNet简介

ResNet(Residual network)是由何恺明等人于2015年提出的深度卷积网络。它的名称来源于其核心结构—残差模块。若说2014年的ImageNet竞赛是VGGNet和GoogLeNet之间的激烈竞争,那么2015年的各项奖项则几乎被ResNet包揽,ResNet赢得了分类任务和目标检测第一名,以及COCO数据集上目标检测和图像分割第一名。

接下来,我们将详细介绍ResNet的创新点,并给出相应的代码实现。

ResNet本质上是为了解决训练深度神经网络时网络退化问题而提出的。所谓网络退化,简单来说,就是深层网络的效果反而不如浅层网络。

在ResNet的论文中,作者开篇就展示了两张图,说明了一个56层的网络在训练集和测试集上的错误率明显高于20层的网络。导致这种现象的原因,除了过拟合、梯度消失和梯度爆炸等,还有一个主要因素就是网络退化。

具体来说,由于非线性激活函数的特性,新添加的网络层很难实现恒等映射,即在理想情况下应该仅传递输入到输出而不引入任何变化。这导致网络层往往无法简单地保留并传递前一层的信息。而ResNet的思路是通过残差结构确保深层网络至少能达到与浅层网络相同的性能,从而解决网络退化问题。

1.5.2 残差结构

说了这么多,到底什么是残差结构呢?其核心思想是引入跨层连接,也称为跳线连接,使得信息能够直接从输入层或者中间层传递到后续层,表示为图1-12中的“跳线”。

图1-12 残差结构示意

x表示输入的特征图,表示残差结构中的计算过程,通过将输入和计算结果相加得到了输出,也就是。这样一来,每个残差结构的输出不仅包括通过卷积得到的特征图,还包括前一层的特征图。这样做一方面避免了梯度在传播时逐渐消失的问题,使得网络训练更加容易;另一方面,当时,输出就等于,相当于自带恒等映射,从而解决了网络退化问题。因此,计算过程学习的其实是输出的差,即,这就是将其称为残差结构的原因。

ResNet基于上述残差结构进一步提出了两种残差模块,用来在不同任务中平衡模型复杂度和性能,如图1-13所示。

图1-13 两种残差模块示意

第一种(见图1-13左图)是由两个3×3的卷积层和一个跨层连接构成,一般用于浅层的ResNet网络。输入特征图经过第一个3×3的卷积层后,进行BN和ReLU激活函数处理,然后通过第二个3×3的卷积层,接着是BN,再与跨层连接相加,经过ReLU激活函数处理后得到输出特征图,特征图维度不变。

第二种(见图1-13右图)则是由三个卷积层和一个跨层连接构成,卷积层包括两个1×1卷积层、一个3×3卷积层,一般用于更深的ResNet网络。正如之前在GoogLeNet中讲过的,1×1卷积可以在尽量保证准确率的同时减小计算量。其中第一个1×1卷积层主要用于降低特征图维度,第二个3×3卷积层是主要的特征提取层,而第三个1×1卷积层用于把特征图维度增加到原来的4倍,同时完成跨层连接。

这两种模块在后面代码实现时还会予以着重讲述,对照代码会理解得更加清晰一些。

1.5.3 ResNet模型结构

ResNet提出了5种不同深度的模型结构,对应表1-2中的第一行,分别是18、34、50、101、152层,与VGGNet的最多19层相比可以说是在深度上优势尽显。

表1-2 ResNet模型结构

层名称

输出大小

18层

34层

50层

101层

152层

conv1

112×112

7×7,64,步长2

conv2_x

56×56

3×3最大池化,步长2

conv3_x

28×28

conv4_x

14×14

conv5_x

7×7

1×1

平均池化,1000维全连接,Softmax

FLOPS

1.8×109

3.6×109

3.8×109

7.6×109

11.3×109

表1-2看起来好像很复杂,实际可分为以下三部分。

第一部分是最上面7×7卷积层+最大池化层。

第二部分是4组不同数量和规格的残差模块,ResNet-18和ResNet-34用的是图1-13中的第一种残差模块,而ResNet-50、ResNet-101、ResNet-152用的是第二种残差模块,方括号后面所乘的数字表示对应残差模块的数量。注意,在输入到输出的过程中,输出特征图的尺度在逐渐减小,但维度是逐层翻倍的。

第三部分是平均池化层+全连接层作为输出。最后的FLOPS是指每秒浮点运算次数(Floating Point Operations Per Second),它是衡量深度学习模型计算复杂度的常用指标之一。

这么分析后,是不是觉得表1-2没那么复杂了呢?后面我们还会用代码依次实现这些模型。

我们再看一张ResNet-34和VGG19的对比图。在图1-14中,位于左侧的是VGG19的结构,位于中间是去除跳线的ResNet-34,位于右侧的则是完整的ResNet-34模型,其中实线表示跨层连接。

图1-14 ResNet-34与VGG19对比示意

需要注意的是,虚线表示这些模块前后的维度并不一致。正如刚刚讲过的,4组残差模块之间进行下采样,减小特征图的尺度但维度逐层翻倍,稍后在代码实现中也能体现出这一点。单从这张对比图也能看出ResNet当年横扫各大奖项的原因了吧。

小 白:为什么ResNet的残差模块有助于解决梯度消失问题呢?

梗老师:残差模块的跨层连接允许梯度更容易地通过网络传播,因为它们通过加法操作将输入特征直接传递给输出,降低了梯度逐渐消失的风险。

1.5.4 代码实现

了解了ResNet的基本思想和结构设计之后,下面我们来看如何用代码实现其相关网络结构。

# 导入必要的库
import torch
import torch.nn as nn
from torchinfo import summary

在导入必要的库后,先来定义前面讲的两种残差模块。第一种残差模块是BasicBlock。expansion用于计算最终输出特征图的通道数,因为BasicBlock中特征图维度不变,所以把expansion设置为1即可,这部分后面还会提到。

构造函数的参数依次为输入通道数inplanes、输出通道数planes、步长stride、下采样层downsample。下采样层用于调整输入x的维度,这个稍后还会解释。然后分别定义第一个3×3卷积层、BN层、激活函数以及第二个3×3卷积层、BN层。保存下采样层和步长,至此构造函数就定义完成了。

下面要做的是定义前向传播函数:先保存输入特征为identity,然后依次经过第一个卷积层+BN层+ReLU,再经过第二个卷积层+BN层。如果需要调整输入x的维度,则将identity设为下采样层的输出,最后将identity和out相加,也就是跨层连接,并使用ReLU激活函数激活后返回即可。到这里,BasicBlock就实现完成了。

# 定义第一种残差模块BasicBlock
class BasicBlock(nn.Module):
    # 设置expansion为1,用于计算最终输出特征图的通道数
    expansion = 1

    # 构造函数,接收输入通道数inplanes,输出通道数planes,步长stride和下采样层downsample
    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super().__init__()
        # 定义第一个卷积层,用3×3的卷积核对输入特征图进行卷积,输出通道数为planes,步长为stride,填充为1
        self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, 
padding=1, bias=False)
        # BN层
        self.bn1 = nn.BatchNorm2d(planes)
        # 激活函数ReLU
        self.relu = nn.ReLU(inplace=True)
        # 定义第二个卷积层,用3×3的卷积核对输入特征图进行卷积,输出通道数为planes,步长默认为1,填充为1
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, padding=1, bias=False)
        # BN层
        self.bn2 = nn.BatchNorm2d(planes)
        # 下采样层,用于调整输入x的维度
        self.downsample = downsample
        # 保存步长
        self.stride = stride

    # 定义前向传播函数
    def forward(self, x):
        # 保存输入特征图
        identity = x

        # 卷积+BN+ReLU
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        # 卷积+BN
        out = self.conv2(out)
        out = self.bn2(out)

        # 如果定义了下采样层,则调整输入x的维度
        if self.downsample is not None:
            identity = self.downsample(x)

        # 将identity和out相加,并使用ReLU激活函数激活
        out += identity
        out = self.relu(out)

        # 返回输出特征图
        return out

接下来我们将实现第二种残差模块Bottleneck。前文提到,这种情况下需要把它的特征图维度增加至原来的4倍,所以这里设置expansion为4。

构造函数的参数和前面一样:输入通道数、输出通道数、步长和下采样层。依次定义第一个1×1卷积层、BN层,第二个3×3卷积层、BN层,第三个1×1卷积层。这里需要注意,输出通道数是原来的4倍。然后是BN层和激活函数ReLU。保存下采样层和步长,构造函数到这里就定义完成了。

接着要做的是定义前向传播函数,同样先保存输入特征为identity,经过第一个1×1卷积层+ BN层+ ReLU,再经过第二个3×3卷积层+ BN层+ ReLU,然后经过第三个1×1卷积层+ BN层。

后面的内容就和第一种残差模块一样了,即定义下采样层,然后将identity与out相加经过ReLU函数后输出。到这里,第二种残差模块Bottleneck就实现完成了。大家可以对照前面残差模块的结构图来查看代码,这样会更好理解。

# 定义第二种残差模块Bottleneck
class Bottleneck(nn.Module):
    # 设置expansion为4,用于计算最终输出特征图的通道数
    expansion = 4

    # 构造函数,接收输入通道数inplanes,输出通道数planes,步长stride和下采样层downsample
    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super().__init__()
        # 定义第一个卷积层,用1×1的卷积核对输入特征图进行卷积,输出通道数为planes
        self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False)
        # BN层
        self.bn1 = nn.BatchNorm2d(planes)
        # 定义第二个卷积层,用3×3的卷积核对第一个卷积层的输出进行卷积,输出通道数为planes,步长为stride,填充为1
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride, 
padding=1, bias=False)
        # BN层
        self.bn2 = nn.BatchNorm2d(planes)
        # 定义第三个卷积层,用1×1的卷积核对第二个卷积层的输出进行卷积,输出通道数为planes * 4
        self.conv3 = nn.Conv2d(planes, planes * 4, kernel_size=1, bias=False)
        # BN层
        self.bn3 = nn.BatchNorm2d(planes * 4)
        # 激活函数ReLU
        self.relu = nn.ReLU(inplace=True)
        # 下采样层,用于调整输入x的维度
        self.downsample = downsample
        # 保存步长
        self.stride = stride

    # 定义前向传播函数
    def forward(self, x):
        # 保存输入特征图
        identity = x

        # 卷积+BN+ReLU
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        # 卷积+BN+ReLU
        out = self.conv2(out)
        out = self.bn2(out)
        out = self.relu(out)

        # 卷积+BN
        out = self.conv3(out)
        out = self.bn3(out)

        # 如果定义了下采样层,则调整输入x的维度
        if self.downsample is not None:
            identity = self.downsample(x)

        # 将identity和out相加,并使用ReLU激活函数激活
        out += identity
        out = self.relu(out)

        # 返回输出特征图
        return out

再往下是定义ResNet的网络结构部分了。构造函数的参数依次为残差模块类型block、残差模块数量列表layers和类别数num_classes。

首先定义一个7×7的卷积层、BN层、激活函数ReLU、3×3最大池化层。这就是1.5.3节中所讲的模型结构的第一部分。接下来是第二部分,初始化输入通道数inplanes为64。为什么是64呢?大家可以看表1-2,可以看到5种ResNet结构初始的输入通道数都是64。然后是4组不同数量和规格的残差模块,这里我们借助_make_layer()函数来构造,下面会展开讲。最后第三部分是定义平均池化层和全连接层作为输出。到这里,构造函数部分就完成了。

_make_layer()函数的作用是根据传入的配置拼接出对应的网络结构,也是代码实现里相对难理解的部分。参数分别是残差模块结构、通道数、残差模块个数和步长。首先将下采样层downsample初始设为None,如果步长不为1或者输入通道数与输出通道数不一致,则需要对输入特征进行调整。然后定义下采样层,包含一个用于调整维度的1×1卷积层和BN层。

注意,这里的输出通道数要乘以对应残差模块的expansion,第一种残差模块对应1,第二种残差模块对应4,其实就对应图1-14中ResNet-34结构图中的虚线部分,在4组残差模块之间包含这样一个下采样层进行维度调整。然后定义一个列表,将第一个block添加到列表中,更新inplanes为输出通道数,其实就是下一个残差模块的输入通道数。剩余的几个block也依次加入列表后就可以返回Sequential()了。通过这个函数可以依次构造4组残差模块。

然后定义ResNet的前向传播函数,对应表1-2中的三个部分。第一部分是7×7卷积层+ BN层+ ReLU+最大池化层,第二部分是4组残差模块,第三部分是平均池化层+全连接层,最后输出即可。

到这里,ResNet的结构就定义完成了,建议大家对照着结构图来查看代码,把它们关联起来会更好理解。

# 定义ResNet的网络结构
class ResNet(nn.Module):

    # 构造函数,接收残差模块类型block、残差模块数量列表layers和类别数num_classes
    def __init__(self, block, layers, num_classes=1000):
        super().__init__()
        # 定义第一个卷积层,用7×7的卷积核对输入特征图进行卷积,输出通道数为64,步长为2,填充为3
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
        # BN层
        self.bn1 = nn.BatchNorm2d(64)
        # 激活函数ReLU
        self.relu = nn.ReLU(inplace=True)
        # 定义3×3最大池化层对特征图进行池化,步长为2,填充为1
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        # 初始化输入通道数inplanes为64
        self.inplanes = 64
        # 定义layer1,使用_make_layer()函数生成一个layer,通道数64,包含layers[0]个block
        self.layer1 = self._make_layer(block, 64, layers[0])
        # 定义layer2,使用_make_layer()函数生成一个layer,通道数128,包含layers[1]个block,步长为2
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
        # 定义layer3,使用_make_layer()函数生成一个layer,通道数256,包含layers[2]个block,步长为2
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
        # 定义layer4,使用_make_layer()函数生成一个layer,通道数512,包含layers[3]个block,步长为2
        self.layer4 = self._make_layer(block, 512, layers[3], stride=2)
        # 平均池化层,输出大小为channel*1*1
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        # 定义全连接层,将输入维度设置为512 * block.expansion,输出维度设置为num_classes
        self.fc = nn.Linear(512 * block.expansion, num_classes)

    # 生成网络结构的函数,根据传入的配置拼接出对应的网络结构
    def _make_layer(self, block, planes, blocks, stride=1):
        # 下采样层一开始为None,用于调整输入的维度
        downsample = None
        # 如果步长不为1或者输入通道数与输出通道数不一致,则需要对输入特征进行调整
        if stride != 1 or self.inplanes != planes * block.expansion:
            # 定义下采样层,包括1×1卷积和BN层
            downsample = nn.Sequential(
                nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size=1, 
stride=stride, bias=False),
                nn.BatchNorm2d(planes * block.expansion),
            )

        # 定义一个layers列表
        layers = []
        # 将第一个block添加到layers列表中
        layers.append(block(self.inplanes, planes, stride, downsample))
        # 更新inplanes为下一个block的输入通道数
        self.inplanes = planes * block.expansion
        # 添加剩余的block到layers列表中
        for i in range(1, blocks):
            layers.append(block(self.inplanes, planes))

        # 返回所有的block
        return nn.Sequential(*layers)

    # 定义前向传播函数
    def forward(self, x):
        # 第一部分,7×7卷积+BN+ReLU+最大池化层
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)

        # 第二部分,4组残差模块
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        # 第三部分,平均池化+全连接层
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)

        # 输出
        return x

最后封装函数,分别对应5个模型ResNet-18、ResNet-34、ResNet-50、ResNet-101、ResNet-152,其中num_classes可以自行设置类别数。ResNet-18和ResNet-34传入的是第一种残差模块BasicBlock,后面三种更深的网络传入的是第二种残差模块Bottleneck,其中的数值表示对应每组残差模块分别包含几个block,与表1-2中的数值完全一致,大家可以对照查看。

# 封装函数对应5个模型,num_classes表示类别数
# 其中数值与网络结构表格中的数值完全一致,可参考论文结构表
def resnet18(num_classes=1000):
    return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes)

def resnet34(num_classes=1000):
    return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes)

def resnet50(num_classes=1000):
    return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes)

def resnet101(num_classes=1000):
    return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes)

def resnet152(num_classes=1000):
    return ResNet(Bottleneck, [3, 8, 36, 3], num_classes=num_classes)

接着我们来查看一下网络结构。还是以ResNet-34为例,调用torchinfo.summary()可以看到刚刚实现的模型信息,包括表1-2中的第一部分、第二部分残差模块和第三部分平均池化层+全连接层,最后计算其参数量。

# 查看模型结构及参数量,input_size表示示例中输入数据的维度信息
summary(resnet34(), input_size=(1, 3, 224, 224))

当然还是有更简便的方法,PyTorch的torchvision.models也集成了可以直接使用的ResNet模型。输出的模型结构和我们前面手动实现的网络是基本一致的,参数量也完全一样,这样能更简单地使用ResNet。对于代码实现部分,如果大家理解起来比较费劲,可以直接使用现成的代码,着重理解其中的设计思想即可。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.resnet34(), input_size=(1, 3, 224, 224))

1.5.5 模型训练

最后我们看一下模型训练部分,模型定义替换成刚刚实现的ResNet-18模型,其他不变。从图1-15所示的损失和准确率曲线可以看到,训练200个epoch之后,测试集上的准确率约为71%,这里使用的是网络深度最浅的ResNet-18,大家也可以自行调整模型和参数进行实验。

# 定义模型、优化器、损失函数
model = resnet18(num_classes=102).to(device)
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
criterion = nn.CrossEntropyLoss()

# 其他部分与AlexNet代码一致
# ...
100%|██████████| 200/200 [1:15:03<00:00, 22.52s/it]

图1-15 损失和准确率曲线

1.5.6 小结

在本节中,我们首先深入讨论了经典的卷积网络ResNet,包括其特性、残差结构以及两种具体的残差模块,还比较了ResNet和VGGNet的模型结构;然后用代码实现了包含残差模块的ResNet结构,着重介绍了ResNet结构的定义部分,并将其与torchvision中集成的模型进行了比较;最后使用Flowers102数据集对模型进行训练,并测试了其效果。

1.6 DenseNet

1.5节详细解读了ResNet的特性、模型结构和代码实现。在本节中,我们聚焦于本章最后一个经典卷积网络—2017年提出的DenseNet。ResNet通过跨层连接解决了网络深度增加所带来的网络退化问题,因此如果把ResNet视为CNN发展历程中最为重要的里程碑,那么DenseNet无疑将这个理念贯彻到了极致。

1.6.1 DenseNet简介

DenseNet名字中的“dense”一词足以说明其特点。该模型进一步引入了密集连接结构,DenseNet的作者凭借此项创新在CVPR 2017中赢得了最佳论文奖。接下来,我们将详细探讨它的创新点和相应的代码实现。

在DenseNet之前,CNN提高效率的方向要么是层次更深以解决网络退化问题,比如1.5节的ResNet,要么就是更宽,比如GoogLeNet的Inception结构。而DenseNet从特征入手,使用连接不同层的稠密块(dense block)来构建模型。采用这种结构有如下两个好处。

建立了不同层之间的连接关系,从而能充分利用特征,进一步缓解了梯度消失问题,使网络加深不再是问题。

通过特征在通道上的连接来实现特征复用(feature reuse),使得DenseNet在参数和计算成本更少的情况下能实现比ResNet更优的性能。

那么到底什么是dense block呢?

从图1-16中很容易看出密集连接结构与ResNet单跳线结构的差异,即该结构包含5层dense block。它采用更加激进的密集连接机制,将所有层相互连接起来,具体来说就是每层都会接收其前面所有层作为额外的输入。因此,相比ResNet,DenseNet有以下两个显著差异。

图1-16 密集连接结构示意

跳线数量明显增加,对于一个L层的网络,有个连接,这就是它被称为密集连接的原因。

跳线作为额外输入,对于每层来说,区别于ResNet的加法操作,DenseNet用的是在维度上的拼接(concatenate),简单理解就是将特征图一层层叠起来。

有了基础的dense block之后,我们就可以进一步拼接DenseNet结构了。DenseNet结构一般由多个block组成,以图1-17为例,使用了3个dense block。前面讲的密集连接结构都是放在dense block里的,而在相邻的两个dense block之间包含一个卷积层和一个池化层,一般将其称为过渡层(transition layer)。

如表1-3所示,DenseNet中提出了4种不同深度的模型结构,对应表1-3中的第一行,分别是121、169、201、264层,相比ResNet进一步加深了网络结构。

图1-17 DenseNet结构拼接示意

表1-3 DenseNet模型结构

层名称

输出大小

DenseNet-121

DenseNet-169

DenseNet-201

DenseNet-264

卷积

112×112

7×7卷积,步长2

池化

56×56

3×3最大池化,步长2

dense block(1)

56×56

transition layer(1)

56×56

1×1卷积

28×28

2×2平均池化,步长2

dense block(2)

28×28

transition layer(2)

28×28

1×1卷积

14×14

2×2平均池化,步长2

dense block(3)

14×14

transition layer(3)

14×14

1×1卷积

7×7

2×2平均池化,步长2

dense block(4)

7×7

classification layer

1×1

7×7全局平均池化

1000维全连接,Softmax

同样,我们来拆解一下表1-3,一共包含4个部分。

第一部分是最上面7×7卷积层+最大池化层。

第二部分是4组不同数量的dense block,以DenseNet-121的第一组dense block为例,方括号后面所乘的6表示这组dense block包含6层,每层都包含一个1×1和一个3×3的卷积层,层结构对于4种模型均一致,区别主要体现在层数上。

第三部分是dense block之间的transition layer,其包含一个1×1卷积层用于降维,以及一个平均池化层用于调整特征图的尺度。

第四部分是平均池化层+全连接层作为输出。

表1-3和ResNet一样都是看起来复杂,梳理一下就清楚了,后面我们会用代码依次实现这些模型。

与ResNet的加法操作不同,密集连接在维度上的拼接更难理解些。也许你会好奇,在一个dense block内如何实现所有层都能接收前面层的特征图呢?我们以DenseNet-121为例拆解一下,如图1-18所示。

图1-18 DenseNet特征图拼接拆解示意

一张图片经过C1也就是卷积+池化处理之后,输出长宽均为56、维度为64的特征图。接下来重点看D1,也就是第一组dense block部分。特征图经过DL1也就是第一层的时候继续展开,经过1×1和3×3的卷积层后输出一个维度为32的特征图,然后与初始输入拼接得到64+32=96维的特征图作为下一层的输入,以此类推,最终得到一个256维的特征图。这样操作后,每层的输入其实就是由原始输入和前面各层的输出构成的,这就实现了密集连接。再后面的T1表示第一个transition layer,一个1×1卷积层降维到原来的一半,然后平均池化层特征图尺度也减半。后面的操作就是重复这个过程。

梗老师:现在知道为什么DenseNet的密集连接有助于梯度传播了吗?

小 白:密集连接使得梯度更容易地从网络的末层传播到早期层,由于每层都可以直接访问前面层的梯度信息,因此解决了梯度逐渐减小的问题。

1.6.2 代码实现

了解了DenseNet的基本思想和结构设计之后,下面我们来看如何用代码实现其相关网络结构。

# 导入必要的库
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchinfo import summary

先来定义最小单元dense layer。前文提到,其包含一个1×1和一个3×3的卷积层。构造函数的参数依次为接收输入通道数num_input_features、输出通道数growth_rate、卷积层的缩放比例bn_size。输入和输出的通道数好理解,缩放比例是什么呢?下面我们具体讲解一下。

每个dense layer包含两部分:第一部分是1×1卷积层,包括BN、ReLU和1×1卷积;第二部分是3×3卷积层,包括BN、ReLU和3×3卷积,分别用nn.Sequential()定义它们,刚刚提到的bn_size就用在这里,1×1卷积层的输出是最终输出通道数的bn_size倍,3×3卷积层的输入通道数就是这个数值,通过这种方式可以调整通道数。然后定义前向传播函数,依次经过这两个部分。最后注意使用torch.cat()也就是拼接操作,这是DenseNet最核心的思想所在,通过这种方式就能把特征图逐层传递下去。

# 定义dense block中的dense layer
class _DenseLayer(nn.Module):
    # 构造函数,接收输入通道数num_input_features,输出通道数growth_rate,卷积层的缩放比例bn_size
    def __init__(self, num_input_features, growth_rate, bn_size):
        super().__init__()
        # 定义第一个卷积层,包括BN层、ReLU激活函数和1×1卷积层
        self.conv1 = nn.Sequential(
            nn.BatchNorm2d(num_input_features),
            nn.ReLU(inplace=True),
            nn.Conv2d(num_input_features, bn_size * growth_rate, kernel_size=1, 
stride=1, bias=False)
        )
        # 定义第二个卷积层,包括BN层、ReLU激活函数和3×3卷积层
        self.conv2 = nn.Sequential(
            nn.BatchNorm2d(bn_size * growth_rate),
            nn.ReLU(inplace=True),
            nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, stride=1, 
padding=1, bias=False)
        )

    # 定义前向传播函数
    def forward(self, x):
        # BN+ReLU+1×1卷积
        out = self.conv1(x)
        # BN+ReLU+3×3卷积
        out = self.conv2(out)
        # 将输入和输出进行拼接后返回结果
        return torch.cat([x, out], 1)

定义完dense layer,再看一下由多个layer组成的dense block。构造函数的参数依次为dense layer的数量num_layers、输入通道数num_input_features、卷积层的缩放比例bn_size和输出通道数growth_rate(后3个参数是上面讲过的)。

接下来看具体实现。首先定义一个保存密集连接层的列表layers,然后构建num_layers个密集连接层,每次循环都创建一个dense layer,传入对应参数。注意,由于前面讲过的拼接操作是有变化的,因此这里的输入通道数需要加上i×growth_rate,i是循环次数,这样是不是就能理解为什么要把输出通道数的变量名称为growth_rate了?本质上是因为输出都会拼接到后面。然后逐层添加到列表layers中,最后调用nn.Sequential()保存为block。前向传播函数直接经过刚刚定义的block输出即可。

# 定义dense block
class _DenseBlock(nn.Module):
    # 构造函数,包含密集连接层的数量num_layers,输入通道数num_input_features,输出通道数growth_rate,卷积层的缩放比例bn_size
    def __init__(self, num_layers, num_input_features, growth_rate, bn_size):
        super().__init__()
        
        # 保存密集连接层的列表
        layers = []
        # 构建num_layers个密集连接层
        for i in range(num_layers):
            # 构建一个密集连接层,其中输入通道数为num_input_features + i × growth_rate逐层递增
            layer = _DenseLayer(num_input_features + i * growth_rate, growth_rate, 
bn_size)
            # 将构建好的密集连接层添加到列表中保存
            layers.append(layer)
        # 将所有密集连接层封装到Sequential中保存为block
        self.block = nn.Sequential(*layers)
        
    # 定义前向传播函数
    def forward(self, x):
        # 经过当前block输出即可
        return self.block(x)

定义dense block之后,接下来就是它们之间的transition layer了。这个比较简单,构造函数的参数就是输入通道数num_input_features和输出通道数num_output_features。其中过渡层用于降维和调整特征图的尺度,包含一个BN层、ReLU激活函数、1×1卷积层、平均池化层。前向传播函数就是经过过渡层后输出。

# 定义dense block之间的transition layer
class _Transition(nn.Module):
    # 构造函数,输入通道数num_input_features,输出通道数num_output_features
    def __init__(self, num_input_features, num_output_features):
        super().__init__()
        # 定义一个过渡层,用于降维和调整特征图的size,包含BN+ReLU+1×1卷积+平均池化层
        self.trans = nn.Sequential(
            nn.BatchNorm2d(num_input_features),
            nn.ReLU(inplace=True),
            nn.Conv2d(num_input_features, num_output_features, kernel_size=1, 
stride=1, bias=False),
            nn.AvgPool2d(kernel_size=2, stride=2)
        )
    
    # 定义前向传播函数
    def forward(self, x):
        # 经过过渡层后输出即可
        return self.trans(x)

完成前面的基础模块定义后,我们就可以实现DenseNet的主体部分了。构造函数的参数依次为dense block的数量block_config、输入通道数num_input_features默认为64、输出通道数growth_rate默认为32、卷积层的缩放比例bn_size默认为4和类别数num_classes默认为1000。

第一部分包含一个7×7卷积层、BN层、ReLU激活函数、最大池化层。

接下来依次定义dense block和Transition,分别对应第二部分和第三部分。先定义一个变量记录特征图通道数和一个保存网络结构的列表,再遍历每层DenseBlock的数量列表,循环中创建一个dense block,依次传入所需参数,加入列表后计算特征图通道数并更新。然后是transition layer的实现。因为只有dense block之间需要transition layer,所以这里加个if判断,如果不是最后一个dense block则添加一个transition layer。定义transition layer,输出特征图维度减半,添加到layers,记录的数值也同样减半。最后添加一个BN层,调用nn.Sequential(),这样就完成了DenseNet最主要的第二部分和第三部分了。

接下来是第四部分全连接层,输出对应的类别数。

最后是forward()函数,经过第一部分的卷积层、第二和第三部分的dense block和transition layer、第四部分的平均池化层和全连接层,输出即可。

到这里DenseNet的结构就定义完成了,乍看可能有些复杂,建议大家将模型结构和代码关联起来反复理解,其中最难的部分其实就是维度对齐,弄明白这一点就会有豁然开朗的感觉。

# 定义DenseNet的网络结构
class DenseNet(nn.Module):
    # 构造函数,包含dense block的数量block_config,输入通道数num_input_features,输出通道数growth_rate,卷积层的缩放比例bn_size和类别数num_classes
    def __init__(self, block_config, num_init_features=64, growth_rate=32, bn_size=4, 
num_classes=1000):

        super().__init__()

        # 第一部分,7×7卷积+BN+ReLU+最大池化层
        self.features = nn.Sequential(
            nn.Conv2d(3, num_init_features, kernel_size=7, stride=2, padding=3, 
bias=False),
            nn.BatchNorm2d(num_init_features),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        )

        # 下面依次定义dense block和transition layer,对应第二部分和第三部分
        num_features = num_init_features # 记录通道数
        layers = [] # 网络结构保存列表
        # 遍历每层dense block的数量列表
        for i, num_layers in enumerate(block_config):
            # 创建dense block,其中包含num_layers个dense layer
            block = _DenseBlock(num_layers=num_layers, num_input_features=num_features, 
                                growth_rate=growth_rate, bn_size=bn_size)
            layers.append(block)
            num_features = num_features + num_layers * growth_rate # 更新特征图维度
            # 如果不是最后一个dense block,则添加一个transition layer,特征图维度除以2
            if i != len(block_config) - 1:
                trans = _Transition(num_input_features=num_features, num_output_
features=num_features // 2)
                layers.append(trans)
                num_features = num_features // 2
        # 添加一个BN
        layers.append(nn.BatchNorm2d(num_features))
        # 调用nn.Sequential完成第二部分和第三部分
        self.denseblock = nn.Sequential(*layers)

        # 第四部分,全连接层
        self.classifier = nn.Linear(num_features, num_classes)

    # 定义前向传播函数
    def forward(self, x):
        # 第一部分
        features = self.features(x)
        # 第二、三部分
        features = self.denseblock(features)
        # ReLU
        out = F.relu(features, inplace=True)
        # 第四部分,平均池化+全连接层
        out = F.avg_pool2d(out, kernel_size=7, stride=1).view(features.size(0), -1)
        out = self.classifier(out)
        # 输出
        return out

下面要做的是封装函数,分别对应4个模型DenseNet-121、DenseNet-161、DenseNet-169、DenseNet-201,其中通过参数num_classes可以自行设置类别数,block_config表示每个DenseBlock里有多少个dense layer,这些数值与表1-3中的对应数值一致,大家可以自行对照查看。

# 封装函数对应4个模型,num_classes表示类别数
# 其中数值与论文中的数值一致
def densenet121(num_classes=1000):
    return DenseNet(block_config=(6, 12, 24, 16), num_init_features=64, 
                    growth_rate=32, num_classes=num_classes)

def densenet161(num_classes=1000):
    return DenseNet(block_config=(6, 12, 36, 24), num_init_features=96, 
                    growth_rate=48, num_classes=num_classes)

def densenet169(num_classes=1000):
    return DenseNet(block_config=(6, 12, 32, 32), num_init_features=64, 
                    growth_rate=32, num_classes=num_classes)

def densenet201(num_classes=1000):
    return DenseNet(block_config=(6, 12, 48, 32), num_init_features=64, 
                    growth_rate=32, num_classes=num_classes)

接下来我们看一下网络结构,以DenseNet-121为例,调用torchinfo.summary()查看刚刚实现的模型信息,可以看到第一部分是卷积层和最大池化层,第二和第三部分依次是dense block和transition layer,第四部分是全连接层,同时还能看到对应的参数量。

# 查看模型结构及参数量,input_size表示示例中输入数据的维度信息
summary(densenet121(), input_size=(1, 3, 224, 224))

同样还有更现成的方法,PyTorch的torchvision.models也集成了DenseNet模型,可以直接使用。对比输出的模型结构,和前面手动实现的网络是一致的,并且其参数量和前面也完全一样,这样能更简单地使用DenseNet。如果大家感觉代码实现部分理解起来比较费劲,可以直接使用现成的代码,着重理解其中的设计思想即可。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.densenet121(), input_size=(1, 3, 224, 224))

1.6.3 模型训练

最后看一下模型训练,只需要把定义的部分替换成刚刚实现的DenseNet-121模型,其他都不变。输出的损失和准确率曲线如图1-19所示,训练200个epoch之后,测试集上的准确率达到了约63.9%,这里使用的是参数量最少的DenseNet-121模型,大家可以自行调整模型和参数进一步实验。

# 定义模型、优化器、损失函数
model = densenet121(num_classes=102).to(device)
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
criterion = nn.CrossEntropyLoss()

# 其他部分与AlexNet代码一致
# ...
100%|██████████| 200/200 [2:18:16<00:00, 41.48s/it]

图1-19 损失和准确率曲线

1.6.4 小结

在本节中,我们详细讲解了经典的卷积网络DenseNet,包括dense block的结构以及整体网络,并进行了详细拆解;随后展示了它的代码实现,包括dense block、transition layer以及总体结构定义,并与torchvision中集成的模型进行了对比;最后使用Flowers102数据集训练模型,并测试了其性能。

在本章中,我们由浅入深地讲述了在CNN发展历程中占据重要地位的6个模型的原理并给出了相应的代码实现,希望大家能够从中体会到这些模型背后所蕴含的思想演变,以及如何将一个想法最终转化为代码实现。在深度学习的攀登之路上,只有多多实践,才能更深入地理解这些原理和方法。加油!

相关图书

深度学习详解
深度学习详解
深度学习高手笔记 卷2:经典应用
深度学习高手笔记 卷2:经典应用
深度学习的数学——使用Python语言
深度学习的数学——使用Python语言
ChatGPT原理与应用开发
ChatGPT原理与应用开发
人工智能和深度学习导论
人工智能和深度学习导论
动手学深度学习(PyTorch版)
动手学深度学习(PyTorch版)

相关文章

相关课程