0%

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

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

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

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

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

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

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

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

原理讲解

控制图像的风格

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

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

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

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

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

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

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

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

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

随机调整图像细节

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

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

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

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

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

靠近平均图像

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

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

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

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

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

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

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

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

总结与展望

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

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

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

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

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

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

快速运行StyleGAN

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

我使用的MMGen版本是0.7.1

安装

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

安装大致分以下几步:

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

下模型权重文件

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

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

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

运行模型

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

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

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

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

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

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

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

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

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

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

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


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

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

安装PyTorch

前言

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

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

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

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

CUDA与cuDNN

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

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

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

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

显卡驱动

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

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

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

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

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

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

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

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

CUDA

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

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

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

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

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

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

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

cuDNN

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

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

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

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

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

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

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

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

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

Anaconda

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

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

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

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

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

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

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

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

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

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

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

1
conda create --name pt python=3.7

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

1
conda activate {env_name}

我的命令是:

1
conda activate pt

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

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

PyTorch

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

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

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

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

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

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

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

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

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

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

用PyTorch实现多分类任务

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

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

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

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

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

欢迎在GitHub上面访问本项目

数据集

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

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

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

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

数据预处理与PyTorch转换

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

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

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

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

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

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

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

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

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

PyTorch多分类模型

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

初始化

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

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

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

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

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

正向传播

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

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

return A

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

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

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

损失函数

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

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

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

评估

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

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

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

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

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

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

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

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

模型训练

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

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

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

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

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

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

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

实验

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

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

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

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

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

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

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

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

1
visualize(train_X, train_Y, plot_result)

我的一个运行结果如下:

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

总结

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

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

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

第二阶段回顾

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

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

数据集划分

浅尝(+1点)

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

偏差与方差

浅尝(+1点)

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

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

前置技能点:正则化

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

正则化

浅尝(+1点)

前置技能点:了解方差

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

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

在损失函数新加一项:

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

精通 - dropout(+1点)

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

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

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

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

参数初始化

浅尝(+0点)

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

入门(+1点)

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

精通(+1点)

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

博闻(+1点)

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

梯度检查

精通(+1点)

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

博闻(+1点)

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

mini-batch

入门(+1点)

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

精通(+2点)

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

指数加权移动平均

入门(+1点)

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

精通(+1点)

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

高级梯度下降算法

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

精通 - Momentum(+1点)

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

精通 - RMSProp(+0点)

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

精通 - Adam(+2点)

前置知识:Momentum, RMSProp

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

博闻(+1点)

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

实现Adam后,获得1觉醒点

学习率衰减

精通(+1点)

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

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

调参

浅尝(+1点)

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

入门(+1点)

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

批归一化

浅尝(+0点)

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

入门(+1点)

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

精通(+1点)

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

精通II(+1点)

  • 动手实现批归一化

博闻(+1点)

完成入门即可学习。

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

多分类问题

浅尝(+1点)

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

精通 - softmax(+1点)

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

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

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

初识Tensorflow

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

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

入门(+1点)

  • 安装GPU版的TensorFlow。

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

前置知识:多分类问题

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

第二阶段自评

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

Level 0:乱搞一通

条件:技能点≤5

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

Level 1:初来乍到

条件:6≤技能点≤15

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

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

Level 2:学有所成

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

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

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

Level 3:登堂入室

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

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

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

Level 4:学无止境

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

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

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

成就

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

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

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

第三阶段知识预览

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

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

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

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

安装Tensorflow

前言

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

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

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

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

CUDA与cuDNN

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

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

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

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

显卡驱动

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

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

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

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

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

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

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

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

CUDA

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

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

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

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

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

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

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

cuDNN

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

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

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

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

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

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

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

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

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

Anaconda

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

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

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

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

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

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

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

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

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

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

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

1
conda create --name pt python=3.7

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

1
conda activate {env_name}

我的命令是:

1
conda activate pt

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

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

TensorFlow

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

1
pip install tensorflow

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

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

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

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

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

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

VSCode代码补全

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

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

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

用TensorFlow实现多分类任务

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

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

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

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

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

欢迎在GitHub上面访问本项目

数据集

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

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

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

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

数据预处理与TensorFlow转换

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

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

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

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

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

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

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

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

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

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

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

TensorFlow多分类模型

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

初始化

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

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

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

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

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

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

正向传播

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

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

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

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

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

损失函数

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

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

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

评估

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

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

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

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

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

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

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

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

模型训练

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

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

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

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

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

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

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

实验

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

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

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

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

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

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

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

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

1
visualize(train_X, train_Y, plot_result)

我的一个运行结果如下:

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

附录:TensorFlow的GPU版本

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

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

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

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

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

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

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

总结

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

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

学习提示

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

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

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

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

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

课堂笔记

调参

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

超参数优先级

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

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

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

超参数采样策略

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

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

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

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

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

超参数搜索尺度

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

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

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

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

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

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

批归一化(Batch Normalization)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

直观理解批归一化的作用

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

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

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

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

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

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

测试时的批归一化

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

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

Softmax 与多分类问题

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

编程框架

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

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

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

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

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

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

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

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

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

总结

这堂课的知识点有:

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

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

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

学习提示

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

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

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

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

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

在这周的课里,我们要更关注每种优化算法的单独、组合使用方法,以及应该在什么场合用什么算法,最后再去关注算法的实现原理。对于多数技术,“会用”一般要优先于“会写”。

课堂笔记

分批梯度下降

这项技术的英文名称取得极其糟糕。之前我们使用的方法被称为”batch gradient descent”, 改进后的方法被称为”mini-batch gradient descent”。但是,这两种方法的本质区别是是否把整个数据集分成多个子集。因此,我们认为我的中文翻译“分批梯度下降”、“整批梯度下降”比原来的英文名词或者“小批量梯度下降”等中文翻译要更贴切名词本身的意思。

使用mini-batch

在之前的学习中,我们都是用整个训练集的平均梯度来更新模型参数的。而如果训练集特别大的话,遍历整个数据集要花很长时间,梯度下降的速度将十分缓慢。

其实,我们不一定要等遍历完了整个数据集再做梯度下降。相较于每次遍历完所有$m$个训练样本再更新,我们可以遍历完一小批次(mini-batch)的样本就更新。让我们来看课件里的一个例子:

假设整个数据集大小$m=5,000,000$。我们可以把数据集划分成5000个mini-batch,其中每一个batch包含1000个数据。做梯度下降时,我们每跑完一个batch里的1000个数据,就用它们的平均梯度去更新参数,再去跑下一个batch。

这里要介绍一个新的标记。设整个数据集$X$的形状是$(n_x, m)(m=5,000,000)$,则第$i$个数据集的标记为 $X^{\lbrace i \rbrace}$ ,形状为$(n_x, 1000)$。

再次总结一下标记:$x^{(i)[j]\lbrace k\rbrace}$中的上标分别表示和第i个样本相关、和第j层相关、和第k个批次的样本集相关。实际上这三个标记几乎不会同时出现。

使用了分批梯度下降后,算法的写法由

1
2
for i in range(m):
update parameters

变成

1
2
3
for i in range(m / batch_size)
for j in range(batch_size):
update parameters

。现在的梯度下降法每进行一次内层循环,就更新一次参数。我们还是把一次内层循环称为一个”step(步)”。此外,我们把一次外层循环称为一个”epoch(直译为’时代’,简称‘代’)”,因为每完成一次外层循环就意味着训练集被遍历了一次。

mini-batch 的损失函数变化趋势

使用分批梯度下降后,损失函数的变化趋势会有所不同:

如图所示,如果是使用整批梯度下降,则损失函数会一直下降。但是,使用分批梯度下降后,损失函数可能会时升时降,但总体趋势保持下降。

这种现象主要是因为之前我们计算的是整个训练集的损失函数,而现在计算的是每个mini-batch的损失函数。每个mini-batch的损失函数时高时低,可以理解为:某批数据比较简单,损失函数较低;另一批数据难度较大,损失函数较大。

选择批次大小

批次大小(batch size)对训练速度有很大的影响。

如果批次过大,甚至极端情况下batch_size=m,那么这等价于整批梯度下降。我们刚刚也学过了,如果数据集过大,整批梯度下降是很慢的。

如果批次过小,甚至小到batch_size=1(这种梯度下降法有一个特别的名字:随机梯度下降(Stochastic Gradient Descent)),那么这种计算方法又会失去向量化计算带来的加速效果。

回想一下第二周的内容:向量化计算指的是一次对多个数据做加法、乘法等运算。这种计算方式比用循环对每个数据做计算要快。

出于折中的考虑,我们一般会选用一个介于1-m之间的数作为批次大小。

如果数据集过小(m<2000),那就没必要使用分批梯度下降,直接拿整个数据集做整批梯度下降即可。

如果数据集再大一点,就可以考虑使用64, 128, 256, 512这些数作为batch_size。这几个数都是2的次幂。由于电脑的硬件容量经常和2的次幂相关,把batch_size恰好设成2的次幂往往能提速。

当然,刚刚也讲了,使用较大batch_size的一个目的是充分利用向量化计算。而向量化计算要求参与运算的数据全部在CPU/GPU内存上。如果设备的内存不够,则设过大的batch_size也没有意义。

一段数据的平均值

在课堂上,这段内容是从数学的角度切入介绍的。我认为这种介绍方式比较突兀。我将从计算机科学的角度切入,用更好理解的方式介绍“指数加权移动平均”。

背景

假设我们绘制了某年每日气温的散点图:

假如让你来描述全年气温的趋势,你会怎么描述呢?

作为人类,我们肯定会说:“这一年里,冬天的气温较低。随后气温逐渐升高,在夏天来到最高值。夏天过后,气温又逐渐下降,直至冬天的最低值。”

但是,要让计算机看懂天气的变化趋势,应该怎么办呢?直接拿相邻的天气的差作为趋势可不行。冬天也会出现第二天气温突然升高的情况,夏天也会出现第二天气温突然降低的情况。我们需要一个能够概括一段时间内气温情况的指标。

移动平均数

一段时间里的值,其实就是几天内多个值的总体情况。多个值的总体情况,可以用平均数表示。严谨地来说,假如这一年有365天,我们用$t$表示这一年每天的天气,那么:

我们可以定义一种叫做移动平均数(Moving Averages) 的指标,表示某天及其前几天温度的平均值。比如对于5天移动平均数$ma$,其定义如下:

假如要让计算机依次输出每天的移动平均数,该怎么编写算法呢?我们来看几个移动平均数的例子:

通过观察,我们可以发现$ma_6=ma_5+(t_6-t_1)/5$,$ma_7=ma_6+(t_7-t_2)/5$。

也就是说,在算n天里的m天移动平均数(我们刚刚计算的是5天移动平均数)时,我们不用在n次的外层循环里再写一个m次的循环,只需要根据前一天的移动平均数,减一个值加一个值即可。这种依次输出移动平均数的算法如下:

1
2
3
4
5
6
7
8
9
10
11
input temperature[0:n]
input m

def get_temperature(i):
return temperature[i] if i >= 0 and i < n else 0

ma = 0
for i in range(n):
ma += (get_temperature(i) - get_temperature(i - m)) / m
ma_i = ma
output ma_i

这种求移动平均数的方法确实很高效。但是,我们上面这个算法是基于所有温度值一次性给出的情况。假如我们正在算今年每天温度的移动平均数,每天的温度是一天一天给出的,而不是一次性给出的,上面的算法应该怎么修改呢?让我们来看修改后的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
input m
temp_i_day_ago = zeros((m))

def update_temperature(t):
for i in range(m - 1):
temp_i_day_ago[i+1] = temp_i_day_ago[i]
temp_i_day_ago[0] = t

ma = 0
for i in range(n):
input t_i
update_temperature(t_i)
ma += (temp_i_day_ago[0] - temp_i_day_ago[m]) / m
ma_i = ma
output ma_i

由于我们不能提前知道每天的天气,我们需要一个大小为m的数组temp_i_day_ago记录前几天的天气,以计算m天移动平均数。

上述代码的时间复杂度还是有优化空间的。可以用更好的写法去掉update_temperature里的循环,把计算每天移动平均数的时间复杂度变为$O(1)$。但是,这份代码的空间复杂度是无法优化的。为了算m天移动平均数,我们必须要维护一个长度为m的数组,空间复杂度一定是$O(m)$。

对于一个变量的m移动平均数,$O(m)$的空间复杂度还算不大。但假如我们要同时维护l个变量的m移动平均数,整个算法的空间复杂度就是$O(ml)$。在l很大的情况下,m对空间的影响是很大的。哪怕m取5这种很小的数,也意味着要多花4倍的空间去存储额外的数据。空间复杂度里这多出来的这个$m$是不能接受的。

指数加权移动平均

作为移动平均数的替代,人们提出了指数加权移动平均数(Exponential Weighted Moving Average) 这种表示一段时期内数据平均值的指标。其计算公式为:

这个公式直观上的意义为:一段时间内的平均温度,等于上一段时间的平均温度与当日温度的加权和。

相比普通的移动平均数,指数平均数最大的好处就是减小了空间复杂度。在迭代更新这个新的移动平均数时,我们只需要维护一个当前平均数$v_i$,一个当前的温度$t_i$即可,空间复杂度为$O(1)$。

让我们进一步理解公式中的参数$\beta$。把公式展开可得:

从这个式子可以看出,之前数据的权重都在以$\beta$的速度指数衰减。根据$(1-\epsilon)^{\frac{1}{\epsilon}} \approx \frac{1}{e}$,并且我们可以认为一个数到了$\frac{1}{e}$就小到可以忽视了,那么指数平均数表示的就是$\frac{1}{1-\beta}$天内数据的平均情况。比如$\beta=0.9$表示的是10天内的平均数据,$\beta=0.99$表示的是100天内的平均数据。

偏差矫正

指数平均数存在一个问题。在刚刚初始化时,指数平均数的值可能不太正确,请看:

让我们把每一项前面的权重加起来。对于$v_1$,前面的权重和是$(1-\beta)$;对于$v_2$,前面的权重和是$(1-\beta)(\beta+1)$。显然,这两个权重和都不为1。而计算平均数时,我们希望所有数据的权重和为1,这样才能反映出数据的真实大小情况。这里出现了权重上的“偏差”。

为了矫正这个偏差,我们应该想办法把权重和矫正为1。观察刚才的算式可以发现,第$i$项的权重和如下:

根据等比数列求和公式,上式化简为:

为了令权重和为1,我们可以令每一项指数平均数都除以这个和,即用下面的式子计算矫正后的指数平均数$v_i’$:

但是,在实践中,由于这个和$1-\beta^i$收敛得很快,我们不会特地写代码做这个矫正。

Momentum

Gradient Descent with Momentum (使用动量的梯度下降) 是一种利用梯度的指数加权移动平均数更新参数的策略。在每次更新学习率时,我们不用本轮梯度的方向作为梯度下降的方向,而是用梯度的指数加权移动平均数作为梯度下降的方向。即对于每个参数,我们用下式做梯度下降:

也就是说,对于每个参数$p$,我们用它的指数平均值$v_{dp}$代替$dp$进行参数的更新。

使用梯度的平均值来更新有什么好处呢?让我们来看一个可视化的例子:

不使用 Momentum 的话,每次参数更新的方向可能变化幅度较大,如上图中的蓝线所示。而使用 Momentum 后,每次参数的更新方向都会在之前的方向上稍作修改,每次的更新方向会更加平缓一点,如上图的红线所示。这样,梯度下降算法可以更快地找到最低点。

在实现时,我们不用去使用偏差矫正。$\beta$取0.9在大多数情况下都适用,有余力的话这个参数也可以调一下。

RMSProp 和 Adam

课堂上并没有对RMSProp的原理做过多的介绍,我们只需要记住它的公式就行。我会在其他文章中介绍这几项技术的原理。

在一个神经网络中,不同的参数需要的更新幅度可能不一样。但是,在默认情况下,所有参数的更新幅度都是一样的(即学习率)。为了平衡各个参数的更新幅度,RMSProp(Root Mean Squared Propagation) 在参数更新公式中添加了一个和参数大小相关的权重$S$。与 Momentum 类似,RMSProp使用了某种移动平均值来平滑这个权重的更新。其梯度下降公式如下:

在编程实现时,我们应该给分母加一个极小值$\epsilon$,防止分母出现0。

Adam (Adaptive Moment Estimation) 是 Momentum 与 RMSProp 的结合版。为了使用Adam,我们要先计算 Momentum 和 RMSProp 的中间变量:

之后,根据前面的偏差矫正,获得这几个变量的矫正值:

如前文所述,在实现时添加偏差矫正意义不大。估计这里加上偏差矫正是因为原论文加了。

最后,进行参数的更新:

和之前一样,这里的$\epsilon$是一个极小值。在编程时添加$\epsilon$,一般都是为了防止分母中出现0。

Adam是目前非常流行的优化算法,它的表现通常都很优秀。为了用好这个优化算法,我们要知道它的超参数该怎么调。在原论文中,这个算法的超参数取值如下:

绝大多数情况下,我们不用手动调这三个超参数。

学习率衰减

训练时的学习率不应该是一成不变的。在优化刚开始时,参数离最优值还差很远,选较大的学习率能加快学习速度。但是,经过了一段时间的学习后,参数离最优值已经比较近了。这时,较大的学习率可能会让参数错过最优值。因此,在训练一段时间后,减小学习率往往能够加快网络的收敛速度。这种训练一段时间后减小学习率的方法叫做学习率衰减

其实学习率衰减只是一种比较宏观的训练策略,并没有绝对正确的学习率衰减方法。我们可以设置初始学习率$\alpha_0$,之后按下面的公式进行学习率衰减:

这个公式非常简单,初始学习率会随着一个衰减率(DecayRate)和训练次数(EpochNum)衰减。

同样,我们还可以使用指数衰减:

或者其他一些奇奇怪怪的衰减方法(k是超参数):

甚至我们可以手动调学习率,每训练一段时间就把学习率调整成一个更小的常数。

总之,学习率衰减是一条启发性的规则。我们可以有意识地在训练中后期调小学习率。

局部最优值

在执行梯度下降算法时,局部最优值可能会影响算法的表现:在局部最优值处,各个参数的导数都是0。梯度是0(所有导数为0),意味着梯度下降法将不再更新了。

在待优化参数较少时,陷入局部最优值是一种比较常见的情况。而对于参数量巨大的深度学习项目来说,整个模型陷入局部最优值是一个几乎不可能发生的事情。某参数在梯度为0时,既有可能是局部最优值,也可能是局部最差值。不妨设两种情况的概率都是0.5。如果整个模型都陷入了局部最优值,那么所有参数都得处于局部最优值上。假设我们的深度学习模型有10000个参数,则一个梯度为0的点是局部最优值的概率是$0.5^{10000}$,这是一个几乎不可能发生的事件。

所以,在深度学习中,更常见的梯度为0的点是鞍点(某处梯度为0,但不是局部最值)。在鞍点处,有很多参数都处于局部最差值上,只要稍微对这些参数做一些扰动,参数就会往更小的方向移动。因此,鞍点不会对学习算法产生影响。

在深度学习中,一种会影响学习速度的情况叫做“高原”(plateau)。在高原处,梯度的值一直都很小。只有跨过了这段区域,学习的速度才会快起来。这种情况的可视化结果如下:

总而言之,深度学习问题和简单的优化问题不太一样,不用过多担心局部最优值的问题。而高原现象确实会影响学习的速度。

总结

这周,我们围绕深度学习的优化算法,学习了许多提升梯度下降法性能的技术。让我们来捋一捋。

首先,我们可以在处理完一小批数据后就执行梯度下降,而不必等处理完整个数据集后再执行。这种算法叫分批梯度下降(mini-batch gradient descent)。这是一种对梯度下降法的通用改进方法,即默认情况下,这种算法都可以和其他改进方法同时使用。

之后,我们学习了移动平均的概念,知道移动平均值可以更平滑地反映数据在一段时间内的趋势。基于移动平均值,有 gradient descent with momentum 和 RMSProp 这两种梯度下降的改进方法。而现在非常常用的 Adam 优化算法是Momentum 和 RMSProp 的结合版。

最后,我们学习了学习率衰减的一些常见方法。

学完本课的内容后,我认为我们应该对相关知识达到下面的掌握程度:

  • 分批梯度下降
    • 了解原理
    • 掌握如何选取合适的 batch size
  • 高级优化算法
    • 了解移动平均数的思想
    • 了解 Adam 的公式
    • 记住 Adam 超参数的常见取值
    • 未来学习了编程框架后,会调用 Momentum,Adam 优化器
  • 学习率衰减
    • 掌握“学习率衰减能加速收敛”这一概念
    • 在训练自己的模型时,能够有意识地去调小学习率
  • 局部最优值
    • 不用管这个问题

代码实战

这周,官方的编程作业还是点集分类。我觉得这个任务太简单了,还是挑战小猫分类比较有意思。

在这周的代码实战项目中,让我们先回顾一下整个项目的框架,再实现这周学到的技术,包括分批梯度下降(Mini-batch Gradient Descent)、高级梯度下降算法(Mini-batch Gradient Descent)、学习率衰减。

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

小猫分类项目框架

数据集

和之前一样,我们即将使用一个 kaggle 上的猫狗分类数据集。我已经写好了读取数据的函数,该函数的定义如下:

1
2
3
4
5
6
def get_cat_set(
data_root: str,
img_shape: Tuple[int, int] = (224, 224),
train_size=1000,
test_size=200,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:

填入数据集根目录、图像Reszie后的大小、一半训练集的大小、一半测试集的大小,我们就能得到预处理后的train_X, train_Y, dev_X, dev_Y。其中,X的形状是(n_x, m), Y的形状是(1, m)n_x是图像的特征数,对于一个大小为(224, 224)的图像,n_x = 224*224*3。m是样本数量,如果train_size=1000,则m=2000

在之前的实战中,我的模型在训练集上的表现都十分糟糕,还没有用到“测试集”的机会。因此,我们之前那个“测试集”,既可以认为是开发集,也可以认为是测试集。从这周开始,出于严谨性的考虑,我准备把之前的“测试集”正式称作开发集(dev set)。

模型类

和之前一样,我们用BaseRegressionModel来表示一个最后一层使用sigmoid,loss用交叉熵的二分类模型基类。这个基类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class BaseRegressionModel(metaclass=abc.ABCMeta):

def __init__(self):
pass

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

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

@abc.abstractmethod
def get_grad_dict(self) -> Dict[str, np.ndarray]:
pass

@abc.abstractmethod
def save(self) -> Dict[str, np.ndarray]:
pass

@abc.abstractmethod
def load(self, state_dict: Dict[str, np.ndarray]):
pass

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

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

在模型类中,和训练有关的主要有forward, backward, get_grad_dict这三个方法,分别表示前向传播、反向传播、梯度获取。

这里要对get_grad_dict做一个说明。之前我们都是直接在模型类里实现梯度下降的,但在这周学了新的优化算法后,这种编程方式就不太方便拓展了。因此,从这周开始,我们应该用一个BaseOptimizer类来表示各种梯度下降算法。模型通过get_grad_dict把梯度传给优化器。

除了和训练相关的方法外,模型类通过save, load来把数据存入/取自一个词典,通过loss, evaluate来获取一些模型评测指标。

BaseRegressionModel只是一个抽象基类。实际上,我在本项目使用的是第四周学习的深层神经网络(任意层数的全连接网络)DeepNetwork。只需要传入每一层神经元个数、每一层的激活函数,我们就能得到一个全连接分类网络:

1
2
3
4
class DeepNetwork(BaseRegressionModel):

def __init__(self, neuron_cnt: List[int], activation_func: List[str]):
...

在第四周代码的基础上,我修改了一下参数初始化的方法。由于隐藏层的激活函数都用的是ReLU,我打算默认使用 He Initialization:

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

除此之外,我没有在这个模型上添加其他高级功能。我也没有添加正则化。现在网络还处于欠拟合状态,等我有资格解决过拟合问题时再去考虑正则化。

优化器类

看完了模型类,接下来,我们来看一看这周要实现的优化器类。所有的优化器类都继承自基类BaseOptimizer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class BaseOptimizer(metaclass=abc.ABCMeta):

def __init__(
self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
lr_scheduler: Callable[[float, int], float] = const_lr) -> None:
self.param_dict = param_dict
self._epoch = 0
self._num_step = 0
self._learning_rate_zero = learning_rate
self._lr_scheduler = lr_scheduler

@property
def epoch(self) -> int:
return self._epoch

@property
def learning_rate(self) -> float:
return self._lr_scheduler(self._learning_rate_zero, self.epoch)

def increase_epoch(self):
self._epoch += 1

def save(self) -> Dict:
return {'epoch': self._epoch, 'num_step': self._num_step}

def load(self, state_dict: Dict):
self._epoch = state_dict['epoch']
self._num_step = state_dict['num_step']

def zero_grad(self):
for k in self.grad_dict:
self.grad_dict[k] = 0

def add_grad(self, grad_dict: Dict[str, np.ndarray]):
for k in self.grad_dict:
self.grad_dict[k] += grad_dict[k]

@abc.abstractmethod
def step(self):
pass

这个优化器基类实现了以下功能:

  • 维护当前的epochstep,以辅助其他参数的计算。
  • 维护当前的学习率,并通过使用_lr_scheduler的方式支持学习率衰减。
  • 定义了从词典中保存/读取优化器的方法save, load
  • 定义了维护的梯度的清空梯度方法zero_grad和新增梯度方法add_grad
  • 允许子类实现step方法,以使用不同策略更新参数。

在后续章节中,我会介绍该如何使用这个基类实现这周学过的优化算法。

模型训练

基于上述的BaseRegressionModelBaseOptimizer,我们可以写出下面的模型训练函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def train(model: BaseRegressionModel,
optimizer: BaseOptimizer,
X,
Y,
total_epoch,
batch_size,
model_name: str = 'model',
save_dir: str = 'work_dirs',
recover_from: Optional[str] = None,
print_interval: int = 100,
dev_X=None,
dev_Y=None):
if recover_from:
load_state_dict(model, optimizer, recover_from)

# Prepare mini_batch
...

for e in range(total_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
mini_batch_Y_hat = model.forward(mini_batch_X)
model.backward(mini_batch_Y)
optimizer.zero_grad()
optimizer.add_grad(model.get_grad_dict())
optimizer.step()

currrent_epoch = optimizer.epoch

if currrent_epoch % print_interval == 0:
# print loss
...

optimizer.increase_epoch()

save_state_dict(model, optimizer,
os.path.join(save_dir, f'{model_name}_latest.npz'))

训练之前,我们可以从模型文件recover_from里读取模型状态和优化器状态。读取数据是通过load_state_dict实现的:

1
2
3
4
5
def load_state_dict(model: BaseRegressionModel, optimizer: BaseOptimizer,
filename: str):
state_dict = np.load(filename)
model.load(state_dict['model'])
optimizer.load(state_dict['optimizer'])

在得到某一批训练数据X, Y后,我们可以用下面的代码执行一步梯度下降:

1
2
3
4
5
Y_hat = model.forward(X)
model.backward(Y)
optimizer.zero_grad()
optimizer.add_grad(model.get_grad_dict())
optimizer.step()

我们会先调用模型的前向传播forward和反向传播backward,令模型存下本轮的梯度。之后,我们重置优化器,把梯度从模型传到优化器,再调用优化器进行更新。

训练代码中,默认使用了mini-batch。我会在后续章节介绍mini-batch的具体实现方法。

完成了梯度的更新后,我们要维护当前的训练代数epoch。训练了几代后,我们可以评测模型在整个训练集和开发集上的性能指标。

1
2
3
4
5
6
7
currrent_epoch = optimizer.epoch

if currrent_epoch % print_interval == 0:
# print loss
...

optimizer.increase_epoch()

最后,模型训练结束后,我们要保存模型。保存模型是通过save_state_dict实现的:

1
2
3
4
def save_state_dict(model: BaseRegressionModel, optimizer: BaseOptimizer,
filename: str):
state_dict = {'model': model.save(), 'optimizer': optimizer.save()}
np.savez(filename, **state_dict)

如果你对np.savez函数不熟,欢迎回顾我在第四周代码实战中对其的介绍。

总之,基于我们定义的BaseRegressionModelBaseOptimizer,我们可以在初始化完这个两个类的对象后,调用train来完成模型的训练。

使用 Mini-batch

注意 I/O 开销!

重申一下,Mini-batch gradient descent 的本意是加快训练速度。如果实现了 Mini-batch 后,程序在其他地方跑得更慢了,那么使用这个算法就毫无意义了。

在我们这个小型的深度学习项目中,从硬盘上读取数据的开销是极大的。下图是执行包含前后处理在内的一轮训练的时间开销分布:

从图中可以看出,相对于一轮训练,读取数据的开销是极大的。读取数据的时间甚至约等于两轮训练的时间。

在之前的项目中,我一直默认是把训练数据全部读取到内存中,然后再进行训练。这样的好处是网络的训练速度不受硬盘读写速度限制,会加快不少,坏处是训练数据的总量受到电脑内存的限制。

在使用分批梯度下降算法时,为了比较算法在性能上的提升,我们应该继续使用相同的数据管理策略,即把数据放到内存中处理。如果换了算法,还换了数据管理策略,把一次性读取数据改成每次需要数据的时候再去读取,那么我们就无法观察到算法对于性能的提升。

事实上,在大型深度学习项目中,模型执行一轮训练的速度很慢,I/O的开销相对来说会小很多。在这种时候,我们可以仅在需要时再读取数据。不过,在这种情况下,我们依然要保证内存/显存足够支持一轮mini-batch的前向/反向传播。这里要注意一下我们这个小demo和实际深度学习项目的区别。

mini-batch 预处理

在执行一个epoch(代)的训练时,我们应该保证训练数据是打乱的,以避免极端数据分布给训练带来的副作用。

epoch 与 epoch 之间 mini-batch 的划分是否相同到不是那么重要。理论上来说,数据越平均越好,最好能每个 epoch 都重新划分 mini-batch。但是,为了加速训练,同时让使用 mini-batch 的逻辑更加易懂,我打算先预处理出 mini-batch,之后每个 epoch 都使用相同的划分。

为了方便之后的处理,我们把每个mini-batch的X和Y都单独存入数组mini_batch_XYs。这样,在之后的训练循环里,每个mini-batch的数据就可以直接拿来用了。以下是预处理mini-batch的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
m = X.shape[1]
indices = np.random.permutation(m)
shuffle_X = X[:, indices]
shuffle_Y = Y[:, indices]
num_mini_batch = math.ceil(m / batch_size)
mini_batch_XYs = []
for i in range(num_mini_batch):
if i == num_mini_batch - 1:
mini_batch_X = shuffle_X[:, i * batch_size:]
mini_batch_Y = shuffle_Y[:, i * batch_size:]
else:
mini_batch_X = shuffle_X[:, i * batch_size:(i + 1) * batch_size]
mini_batch_Y = shuffle_Y[:, i * batch_size:(i + 1) * batch_size]
mini_batch_XYs.append((mini_batch_X, mini_batch_Y))

在这段代码中,我们首先用第二周编程练习中学过的permutation生成一个随机排列,并根据这个随机排列打乱数据。

之后的代码就是一段常见的数据除法分块逻辑。对于除得尽和除不尽的mini-batch,我们分开处理,提取出每个mini_batch的X和Y。

mini-batch 训练

预处理得当的话,用mini-batch进行训练的代码非常简洁。我们只需要在原来的训练循环里加一个对mini-batch的遍历即可:

1
2
3
4
5
for e in range(num_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
mini_batch_Y_hat = model.forward(mini_batch_X)
model.backward(mini_batch_Y)
model.gradient_descent(learning_rate)

mini-batch 的损失函数曲线

和我们在课堂里学的一样,使用mini-batch后,损失函数的曲线可能不像之前那么平滑。这是因为我们画损失函数曲线时用的是每个mini-batch上的损失函数,而不是整个训练集的损失函数。我得到的一个mini-batch损失函数曲线如下:

在训练时,我顺手存了一下每个mini-batch的梯度,并在训练结束后对它们进行可视化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mini_batch_loss_list = []
for e in range(num_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
...

if plot_mini_batch:
loss = model.loss(mini_batch_Y, mini_batch_Y_hat)
mini_batch_loss_list.append(loss)
if plot_mini_batch:
plot_length = len(mini_batch_loss_list)
plot_x = np.linspace(0, plot_length, plot_length)
plot_y = np.array(mini_batch_loss_list)
plt.plot(plot_x, plot_y)
plt.show()

实现高级优化算法

有了基类BaseOptimizer后,我们只需要实现子类的构造函数和更新函数,就可以实现各种各样的改进梯度下降算法了。让我们看一下这周学习的Momentum, RMSProp, Adam该如何实现。

Momentum

Momentum的主要实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Momentum(BaseOptimizer):

def __init__(self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
beta: float = 0.9,
from_scratch=False) -> None:
super().__init__(param_dict, learning_rate)
self.beta = beta
self.grad_dict = deepcopy(self.param_dict)
if from_scratch:
self.velocity_dict = deepcopy(self.param_dict)
for k in self.velocity_dict:
self.velocity_dict[k] = 0

def step(self):
self._num_step += 1
for k in self.param_dict:
self.velocity_dict[k] = self.beta * self.velocity_dict[k] + \
(1 - self.beta) * self.grad_dict[k]
self.param_dict[k] -= self.learning_rate * self.velocity_dict[k]

在Momentum中,我们主要是维护velocity_dict这个变量。根据课堂里学过的知识,这个变量的值等于梯度的指数移动平均值。因此,我们只需要在step里维护一个指数平均数即可。

为了保存优化器的状态,我们应该在save, load里保存velocity_dict

1
2
3
4
5
6
7
8
9
10
11
12
def save(self) -> Dict:
state_dict = super().save()
state_dict['velocity_dict'] = self.velocity_dict
return state_dict

def load(self, state_dict: Dict):
self.velocity_dict = state_dict.get('velocity_dict', None)
if self.velocity_dict is None:
self.velocity_dict = deepcopy(self.param_dict)
for k in self.velocity_dict:
self.velocity_dict[k] = 0
super().load(state_dict)

RMSProp

RMSProp的主要实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class RMSProp(BaseOptimizer):

def __init__(self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
beta: float = 0.9,
eps: float = 1e-6,
from_scratch=False,
correct_param=True) -> None:
super().__init__(param_dict, learning_rate)
self.beta = beta
self.eps = eps
self.grad_dict = deepcopy(self.param_dict)
self.correct_param = correct_param
if from_scratch:
self.s_dict = deepcopy(self.param_dict)
for k in self.s_dict:
self.s_dict[k] = 0

def step(self):
self._num_step += 1
for k in self.param_dict:
self.s_dict[k] = self.beta * self.s_dict[k] + \
(1 - self.beta) * np.square(self.grad_dict[k])
if self.correct_param:
s = self.s_dict[k] / (1 - self.beta**self._num_step)
else:
s = self.s_dict[k]
self.param_dict[k] -= self.learning_rate * self.grad_dict[k] / (
np.sqrt(s + self.eps))

和Momentum类似,我们要维护一个指数平均数权重s_dict,并在更新参数时算上这个权重。由于RMSProp是除法运算,为了防止偶尔出现的除以0现象,我们要在分母里加一个极小值eps

我在这个优化器中加入了偏差校准功能。如果开启了校准,指数平均数会除以一个(1 - self.beta**self._num_step)

类似地,RMSProp中也用save, load来保存状态s_dict

1
2
3
4
5
6
7
8
9
10
11
12
def save(self) -> Dict:
state_dict = super().save()
state_dict['s_dict'] = self.s_dict
return state_dict

def load(self, state_dict: Dict):
self.s_dict = state_dict.get('s_dict', None)
if self.s_dict is None:
self.s_dict = deepcopy(self.param_dict)
for k in self.s_dict:
self.s_dict[k] = 0
super().load(state_dict)

注意,RMSProp实际上是对学习率进行了一个放缩。在把模型的优化算法从Momentum改成RMSProp后,学习率要从头调整。一般来说,RMSProp里的权重s_dict是一个小于1的数。这个数做了分母,等价于放大了学习率。因此,使用RMSProp后,可以先尝试把学习率调小100倍左右,再做进一步的调整。

Adam

Adam 的主要实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Adam(BaseOptimizer):

def __init__(self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
beta1: float = 0.9,
beta2: float = 0.999,
eps: float = 1e-8,
from_scratch=False,
correct_param=True) -> None:
super().__init__(param_dict, learning_rate)
self.beta1 = beta1
self.beta2 = beta2
self.eps = eps
self.grad_dict = deepcopy(self.param_dict)
self.correct_param = correct_param
if from_scratch:
self.v_dict = deepcopy(self.param_dict)
self.s_dict = deepcopy(self.param_dict)
for k in self.v_dict:
self.v_dict[k] = 0
self.s_dict[k] = 0

def step(self):
self._num_step += 1
for k in self.param_dict:
self.v_dict[k] = self.beta1 * self.v_dict[k] + \
(1 - self.beta1) * self.grad_dict[k]
self.s_dict[k] = self.beta2 * self.s_dict[k] + \
(1 - self.beta2) * (self.grad_dict[k] ** 2)
if self.correct_param:
v = self.v_dict[k] / (1 - self.beta1**self._num_step)
s = self.s_dict[k] / (1 - self.beta2**self._num_step)
else:
v = self.v_dict[k]
s = self.s_dict[k]
self.param_dict[k] -= self.learning_rate * v / (np.sqrt(s) +
self.eps)

Adam 就是把 Momentum 和 RMSProp 结合一下。在Adam中,我们维护v_dicts_dict两个变量,并根据公式利用这两个变量更新参数。

这里有一个小细节:在Adam中,eps是写在根号外的,而RMSProp中eps是在根号里面的。这是为了与原论文统一。其实eps写哪都差不多,只要不让分母为0即可。

类似地,Adam要在状态词典里保存两个变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def save(self) -> Dict:
state_dict = super().save()
state_dict['v_dict'] = self.v_dict
state_dict['s_dict'] = self.s_dict
return state_dict

def load(self, state_dict: Dict):
self.v_dict = state_dict.get('v_dict', None)
self.s_dict = state_dict.get('s_dict', None)
if self.v_dict is None:
self.v_dict = deepcopy(self.param_dict)
for k in self.v_dict:
self.v_dict[k] = 0
if self.s_dict is None:
self.s_dict = deepcopy(self.param_dict)
for k in self.s_dict:
self.s_dict[k] = 0
super().load(state_dict)

Adam使用的学习率和RMSProp差不多。如果有一个在RMSProp上调好的学习率,可以直接从那个学习率开始调。

学习率衰减

要实现学习率衰减非常容易,我们只需要用一个实时计算学习率的学习率getter来代替静态的学习率即可。在BaseOptimizer中,我们可以这样实现学习率衰减:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BaseOptimizer(metaclass=abc.ABCMeta):

def __init__(
self,
param_dict: Dict[str, np.ndarray],
learning_rate: float,
lr_scheduler: Callable[[float, int], float] = const_lr) -> None:
self.param_dict = param_dict
self._epoch = 0
self._num_step = 0
self._learning_rate_zero = learning_rate
self._lr_scheduler = lr_scheduler

@property
def learning_rate(self) -> float:
return self._lr_scheduler(self._learning_rate_zero, self.epoch)

BaseOptimizer类中,我们用@property装饰器装饰一个learning_rate方法,以实现一个getter函数。这样,我们在获取optimizer.learning_rate这个属性时,实际上是在调用learning_rate这个函数。

getter中,我们用_lr_scheduler来实时计算一个学习率。_lr_scheduler是一个函数,该函数应该接受初始学习率、当前的epoch这两个变量,返回一个当前学习率。通过修改这个_lr_scheduler,我们就能使用不同的学习率衰减算法。

在代码中,我只实现了两个简单的学习率衰减函数。首先是常数学习率:

1
2
def const_lr(learning_rate_zero: float, epoch: int) -> float:
return learning_rate_zero

之后是课堂上学过的双曲线衰减函数:

1
2
3
4
5
6
def get_hyperbola_func(decay_rate: float) -> Callable[[float, int], float]:

def scheduler(learning_rate_zero: float, epoch: int):
return learning_rate_zero / (1 + epoch * decay_rate)

return scheduler

get_hyperbola_func是一个返回函数的函数。我们可以用get_hyperbola_func(decay_rate)生成一个某衰减率的学习率衰减函数。

实验结果

经实验,高级优化技术确实令训练速度有显著的提升。为了比较不同优化技术的性能,我使用2000个小猫分类样本作为训练集,使用了下图所示的全连接网络,比较了不同batch size不同优化算法不同学习率衰减方法下整个数据集的损失函数变化趋势。

以下是实验的结果:

首先,我比较了不同batch size下的mini-batch梯度下降。

从理论上来看,对于同一个数据集,执行相同的epoch,batch size越小,执行优化的次数越多,优化的效果越好。但是,batch size越小,执行一个epoch花的时间就越多。batch size过小的话,计算单元的向量化计算无法得到充分利用,算法的优化效率(单位时间内的优化量)反而下降了。

上面的实验结果和理论一致。执行相同的epoch,batch size越小,优化的效果越好。同时,batch size越小,误差也更容易出现震荡。虽然看上去batch size越小效果就越好,但由于向量化计算的原因,batch size为64,128,2000时跑一个epoch都差不多快,batch size为8时跑一个epoch就很慢了。我还尝试了batch size为1的随机梯度下降,算法跑一个epoch的速度奇慢无比,程序运行效率极低。最终,我把64作为所有优化算法的batch size。

之后,我比较了普通梯度下降、Momentum、RMSProp、Adam的优化结果。在普通梯度下降和Momentum中,我的学习率为1e-3;在RMSProp和Adam中,我的学习率为1e-5。

由于不同算法的学习率“尺度”不一样,因此,应该去比较普通梯度下降和Momentum,RMSProp和Adam这两组学习率尺度一样的实验。

对比普通梯度下降和Momentum,可以看出Momentum能够显著地提升梯度下降的性能,并且让误差的变化更加平滑。

对比RMSProp和Adam,可以看出学习率相同且偏小的情况下,Adam优于RMSProp。

感觉Adam的性能还是最优秀的。如果把Adam的学习率再调一调,优化效果应该能够超过其他算法。

最后,我还尝试了三个学习率衰减策略实验。每次实验都使用Adam优化器,初始学习率都是1e-5。第一次实验固定学习率,之后的两次实验分别使用衰减系数0.2,0.005的双曲线衰减公式。以下是实验结果:

从图中可以看出,由于初始学习率较低,在使用了比较大的衰减系数(=0.2)时,虽然学习的过程很平滑,但是学习速度较慢。而如果使用了恰当的衰减系数,虽然学习率在缓缓降低,但学习的步伐可能更加恰当,学习的速度反而变快了。

不过,RMSProp本身就自带调度学习率的效果。主动使用学习率衰减的效果可能没有那么明显。相比mini-batch和高级优化算法,学习率衰减确实只能算是一种可选的策略。

感想

我的实验还做得不是很充分。理论上可以再调一调学习率,更加公平地比较不同的学习算法。但是,我已经没有动力去进一步优化超参数了——由于目前学习算法的性能过于优秀,模型已经在训练集上过拟合了,训练准确率达到了80%多,远大于58%的开发准确率。因此,根据上一周学的知识,我的下一步任务不是继续降低训练误差,而是应该使用正则化方法或者其他手段,提高模型的泛化能力。在后续的课程中,我们还会接着学习改进深度学习项目的方法,届时我将继续改进这个小猫分类模型。

其实,过拟合对我来说是一件可喜可贺的事情。前两周,仅使用普通梯度下降时,模型的训练准确率和测试准确率都很低,我还在怀疑是不是我的代码写错了。现在看来,这完全是梯度下降算法的锅。朴素的梯度下降算法的性能实在是太差了。稍微使用了mini-batch、高级优化算法等技术后,模型的训练速度就能有一个质的飞跃。在深度学习项目中,mini-batch, Adam优化器应该成为优化算法的默认配置。

学习提示

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

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

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

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

课堂笔记

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

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

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

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

三种数据集的定义

它们三者的区别如下:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

偏差与方差

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

改进机器学习的基本方法

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

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

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

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

正则化 (Regularization)

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

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

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

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

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

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

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

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

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

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

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

其中,

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

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

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

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

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

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

为什么正则项能减少方差

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

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

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

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

Dropout (失活)

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

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

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

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

其实现代码如下:

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

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

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

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

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

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

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

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

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

其他正则化方法

  1. 数据增强

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

  1. 提前停止 (early stopping)

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

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

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

输入归一化(Normalization)

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

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

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

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

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

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

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

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

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

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

梯度爆炸/弥散

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

梯度检查

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

导数估计公式如下:

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

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

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

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

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

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

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

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

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

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

  • 无法调试 dropout.

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

总结

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

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

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

代码实战

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

参数初始化

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

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

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

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

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

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

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

正则化

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

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

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

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

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

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

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

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

网络成功规避了过拟合。

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

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

return A

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

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

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

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

概览

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

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

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

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

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

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

神经网络的结构

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

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

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

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

神经网络的训练

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

重复执行以下步骤:

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

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

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

用numpy实现神经网络

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

第一阶段学习情况自评

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

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

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

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

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

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

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

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

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

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

第二阶段知识预览

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

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

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

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

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


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

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

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

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

基于 CNN 的图像风格迁移

什么是风格迁移

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

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

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

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

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

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

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

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

复制一幅图片

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

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

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

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

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

这段逻辑可以PyTorch实现:

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

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

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

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

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

1
optimizer = optim.LBFGS([input_img])

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

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

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

return loss

optimizer.step(closure)

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

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

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

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

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

拟合神经网络的输出

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

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

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

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

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

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

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

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

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

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

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

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

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

return loss

optimizer.step(closure)

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

风格+内容=风格迁移

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

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

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

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

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

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

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

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

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

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

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

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

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

思考

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

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

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

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

参考文献

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

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

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

附录:PyTorch 实现风格迁移

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

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

准备工作

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

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

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

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

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

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


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

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

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

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

误差计算

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

首先,编写内容误差:

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

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

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

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

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

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

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


class StyleLoss(torch.nn.Module):

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

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

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

获取预训练模型

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
default_content_layers = ['conv_4']
default_style_layers = ['conv_1', 'conv_2', 'conv_3', 'conv_4', 'conv_5']

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

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

model.add_module(name, layer)

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

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

if num_loss >= expected_num_loss:
break

return model, content_losses, style_losses

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

用梯度下降生成图像

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
optimizer = optim.LBFGS([input_img])
steps = 0
prev_loss = 0
while steps <= 1000 and prev_loss < 100:

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

optimizer.step(closure)

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

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

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

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

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

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

彩蛋

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

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

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

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

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

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

吐槽

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

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

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

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

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

课堂笔记

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

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

$L$表示网络的层数。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

为什么用更深的网络

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

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

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

深度神经网络的训练流程

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

首先是前向传播;

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

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

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

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

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

参数与超参数

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

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

  • $W$
  • $b$

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

而超参数则包括:

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

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

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

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

神经网络与大脑

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

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

总结

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

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

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

代码实战

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

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

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

通用分类器类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class BaseRegressionModel(metaclass=abc.ABCMeta):

def __init__(self):
pass

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

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

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

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

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

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

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

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

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

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

工具函数

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

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


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

实现任意层数的神经网络

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

1
class DeepNetwork(BaseRegressionModel):

模型初始化

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

我们要在模型初始化时确定模型的结构,因此我们需要刚刚提到的神经网络的层数、每层的神经元数、激活函数这三个信息。在初始化函数中,我们只需要传入每层神经元数的列表、激活函数名的列表即可,神经网络的层数可以通过列表长度来获取。

注意,在构建神经网络时,我们不仅要设置每一层的神经元数,还需要设置输入层的向量长度(回忆一下,输入层虽然不计入网络的层数,但我们还需要根据输入的向量长度设置参数矩阵W的形状)。因此,神经元数列表比激活函数名列表多了一个元素:

1
assert len(neuron_cnt) - 1 == len(activation_func)

获取了构建模型所需信息(超参数)后,我们按照公式,根据这些信息初始化模型的参数:

1
2
3
4
for i in range(self.num_layer):
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 0.2)
self.b.append(np.zeros((neuron_cnt[i + 1], 1)))

别忘了,我们要随机初始化W,且令W为一个较小的值。这里我随便取了个0.2。后面的课程会介绍这个值该怎么设置。

此外,我们还要先准备好缓存的列表:

1
2
3
4
self.Z_cache = [None] * self.num_layer
self.A_cache = [None] * (self.num_layer + 1)
self.dW_cache = [None] * self.num_layer
self.db_cache = [None] * self.num_layer

由于输入层(A0)的信息也要保留,因此A_cache的长度要多1。

前向传播

回忆这节课提到的前向传播公式:

我们只需要按照公式做运算并缓存变量即可(下标和公式不完全对应):

1
2
3
4
5
6
7
8
9
10
11
12
def forward(self, X, train_mode=True):
if train_mode:
self.m = X.shape[1]
A = X
self.A_cache[0] = A
for i in range(self.num_layer):
Z = np.dot(self.W[i], A) + self.b[i]
A = get_activation_func(self.activation_func[i])(Z)
if train_mode:
self.Z_cache[i] = Z
self.A_cache[i + 1] = A
return A

注意,虽然公式里写了要缓存模型参数W, b,但实际上在代码实现时,模型参数本来就是类的成员属性,不需要额外去缓存它们。

反向传播

同样,按照公式翻译反向传播即可:

1
2
3
4
5
6
7
8
9
10
11
12
def backward(self, Y):
dA = -Y / self.A_cache[-1] + (1 - Y) / (1 - self.A_cache[-1])
assert (self.m == Y.shape[1])

for i in range(self.num_layer - 1, -1, -1):
dZ = dA * get_activation_de_func(self.activation_func[i])(
self.Z_cache[i])
dW = np.dot(dZ, self.A_cache[i].T) / self.m
db = np.mean(dZ, axis=1, keepdims=True)
dA = np.dot(self.W[i].T, dZ)
self.dW_cache[i] = dW
self.db_cache[i] = db

这里除了第一个dA要由误差函数的导数单独算出来外,其他的梯度直接照着公式算就可以了。由于我们把反向传播和更新参数分成了两步,这里额外缓存了dW, db的值。

梯度下降

这次,梯度下降需要循环对所有参数进行:

1
2
3
4
def gradient_descent(self, learning_rate):
for i in range(self.num_layer):
self.W[i] -= learning_rate * self.dW_cache[i]
self.b[i] -= learning_rate * self.db_cache[i]

存取模型

在介绍存取模型的函数之前,我们要认识两个新的numpy API。

第一个API是np.savez(filename, a_name=a, b_name=b, ...)。它可以把ndarray类型的数据a, b, ...以键值对的形式记录进一个.npz文件中,其中键值对的键是数据的名称,值是数据的值。

第二个API是np.load(filename)。它可以从.npz里读取出一个词典。词典中存储的键值对就是我们刚刚保存的键值对。

比如,我们可以用如下方法存取W, b两个ndarray

1
2
3
4
5
6
W = np.zeros((1, 1))
b = np.zeros((1, 1))
np.savez('a.npz', W=W, b=b)
params = np.load('a.npz')
assert W == params['W']
assert b == params['b']

学会了这两个API的用法后,我们来看看该怎么存取神经网络的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def save(self, filename: str):
save_dict = {}
for i in range(len(self.W)):
save_dict['W' + str(i)] = self.W[i]
for i in range(len(self.b)):
save_dict['b' + str(i)] = self.b[i]
np.savez(filename, **save_dict)

def load(self, filename: str):
params = np.load(filename)
for i in range(len(self.W)):
self.W[i] = params['W' + str(i)]
for i in range(len(self.b)):
self.b[i] = params['b' + str(i)]

和刚刚介绍的用法一样,这里我们要给神经网络中每一个参数取一个独一无二的名字,再把所有名字和值合并成键值对。保存和读取,就是对键值对的写和读。

这里我使用了**save_dict这种传参方式。在Python中,func(**dict)的作用是把一个词典的值当作函数的键值对参数。比如我要写func(a=a, b=b),我可以定义一个词典d={'a':a, 'b':b},再用func(**d)把词典传入函数的键值对参数。

小猫分类器

这次,我们还是使用和第二篇笔记中相同的猫狗数据集,作为这次任务的训练集和测试集:

1
2
3
4
5
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
# train_X: [n_x, m]
# train_Y: [1, m]

这次我直接在get_cat_set函数里把输入数据的形状调好了,总算不用在main函数看乱糟糟的代码了。

之后,我们根据输入的向量长度,初始化一个较深的神经网络:

1
2
3
n_x = train_X.shape[0]
model = DeepNetwork([n_x, 30, 30, 20, 20, 1],
['relu', 'relu', 'relu', 'relu', 'sigmoid'])

如这段代码所示,我创建了一个有4个隐藏层的神经网络,其中隐藏层的通道数分别为30, 30, 20, 20。除了最后一层用sigmoid以外,每一层都用relu作为激活函数。

初始化完模型后,我们可以开始训练模型了:

1
2
3
4
5
6
7
8
9
10
model.load('work_dirs/model.npz')
train(model,
train_X,
train_Y,
500,
learning_rate=0.01,
print_interval=10,
test_X=test_X,
test_Y=test_Y)
model.save('work_dirs/model.npz')

其中,模型是否要读取和保存是可以“灵活地”注释掉的。只需要简单地调用train函数,我们的模型就可以训起来了。

实验结果

本来,写完了代码,讨论实验结果,是一件令人心情愉悦的事情:忙了大半天,总算能看一看自己的成果了。但是,在深度学习项目中,实验其实是最烦人的部分。

如第一课所述,深度学习是一个以实验为主的研究方向,深度学习项目是建立在“实验-开发”这一循环上的。一旦实验效果不佳,你就得去重新调代码。普通的编程项目,你能预计程序有怎样的输出,出了问题你能顺藤摸瓜找到bug。而对于深度学习项目,模型效果不佳,可能是代码有bug,也可能是模型不行。哪怕是知道是模型不行,你也很难确切地知道应该怎么去提升模型。因此,对于深度学习项目,开始实验,仅仅是麻烦的开始。

这不,在开启这周的小猫分类任务实验时,我是满怀期待的:上次用的逻辑回归确实太烂了。这周换了一个这么强大的模型,应该没问题了吧。

结果呢,模型跑了半天,精度还比不过逻辑回归。

我也不好去直接debug啊。我只好对着屏幕大眼瞪小眼,硬生生地用我的人脑编译器去调试代码。

我看了很久,屏幕都快被我眼睛发出的射线射穿了,我还是找不到bug。

我只好断定:这不是代码的问题,是模型或数据的问题。

调了半天超参数后,开始训练。模型用蜗牛般的速度训练了一个小时后,总算达到了 60.5% 的准确率。还好,这个模型没有太丢人,总算比之前逻辑回归的 57.5% 要高上一点点了。

可又过了半小时,这模型的准确率又只有 58.5% 了,准确度就再也上不去了。

这个结果实在是太气人了。相比逻辑回归,这么深的网络竟然只有这么小的提升。

消气后,我冷静地分析了下为什么这个“深度”神经网络的提升这么小:

  1. 上次逻辑回归使用的测试集太小,结果不准确。模型可能碰巧多猜对了几次。
  2. 还有很多训练优化的手段我没有用上。

还有一些其他方面的原因。现在所有运算都是在CPU上运行的,速度特别慢。如果放到GPU上运算,模型的训练会快上很多。实验速度快了,就有更多的机会去调试模型的超参数,得到更优的模型。

其实,我已经学完了后面几周的课程,知道该怎么优化神经网络的训练;我也知道该怎么在GPU上训练模型。但是,出于教学的考虑,为了让使用的知识尽可能少,我没有提前去使用一些更高级的优化方法和编程手段。我大概只发挥了一成的模型优化水平,使用GPU后实验速度保守估计提升10倍。这样算来,我现在展示的实力,最多只有真实实力的1%。一旦我稍稍拿出5成实力,这个模型的性能就会有显著提升;一旦我释放所有能量,再去多看几篇论文,使用更加优秀的分类模型,那我的模型在这个数据集上的分类精度就可以登顶全球了。只是这样太张扬了不太好。

这样一想,我也没有必要为这个模型的垃圾性能而生气。后面还有的是改进的机会。希望在后面的编程实战中,我们能一点一点见证这个分类模型的提升。

代码链接:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/DeepNetwork