0%

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

第二阶段回顾

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

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

数据集划分

浅尝(+1点)

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

偏差与方差

浅尝(+1点)

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

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

前置技能点:正则化

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

正则化

浅尝(+1点)

前置技能点:了解方差

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

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

在损失函数新加一项:

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

精通 - dropout(+1点)

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

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

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

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

参数初始化

浅尝(+0点)

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

入门(+1点)

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

精通(+1点)

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

博闻(+1点)

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

梯度检查

精通(+1点)

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

博闻(+1点)

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

mini-batch

入门(+1点)

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

精通(+2点)

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

指数加权移动平均

入门(+1点)

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

精通(+1点)

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

高级梯度下降算法

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

精通 - Momentum(+1点)

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

精通 - RMSProp(+0点)

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

精通 - Adam(+2点)

前置知识:Momentum, RMSProp

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

博闻(+1点)

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

实现Adam后,获得1觉醒点

学习率衰减

精通(+1点)

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

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

调参

浅尝(+1点)

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

入门(+1点)

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

批归一化

浅尝(+0点)

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

入门(+1点)

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

精通(+1点)

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

精通II(+1点)

  • 动手实现批归一化

博闻(+1点)

完成入门即可学习。

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

多分类问题

浅尝(+1点)

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

精通 - softmax(+1点)

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

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

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

初识Tensorflow

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

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

入门(+1点)

  • 安装GPU版的TensorFlow。

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

前置知识:多分类问题

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

第二阶段自评

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

Level 0:乱搞一通

条件:技能点≤5

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

Level 1:初来乍到

条件:6≤技能点≤15

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

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

Level 2:学有所成

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

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

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

Level 3:登堂入室

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

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

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

Level 4:学无止境

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

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

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

成就

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

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

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

第三阶段知识预览

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

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

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

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

安装Tensorflow

前言

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

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

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

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

CUDA与cuDNN

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

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

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

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

显卡驱动

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

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

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

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

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

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

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

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

CUDA

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

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

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

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

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

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

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

cuDNN

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

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

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

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

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

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

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

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

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

Anaconda

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

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

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

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

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

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

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

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

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

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

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

1
conda create --name pt python=3.7

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

1
conda activate {env_name}

我的命令是:

1
conda activate pt

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

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

TensorFlow

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

1
pip install tensorflow

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

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

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

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

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

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

VSCode代码补全

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

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

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

用TensorFlow实现多分类任务

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

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

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

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

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

欢迎在GitHub上面访问本项目

数据集

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

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

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

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

数据预处理与TensorFlow转换

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

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

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

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

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

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

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

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

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

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

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

TensorFlow多分类模型

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

初始化

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

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

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

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

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

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

正向传播

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

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

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

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

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

损失函数

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

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

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

评估

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

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

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

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

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

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

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

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

模型训练

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

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

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

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

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

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

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

实验

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

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

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

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

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

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

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

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

1
visualize(train_X, train_Y, plot_result)

我的一个运行结果如下:

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

附录:TensorFlow的GPU版本

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

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

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

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

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

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

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

总结

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

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

学习提示

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

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

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

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

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

课堂笔记

调参

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

超参数优先级

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

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

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

超参数采样策略

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

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

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

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

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

超参数搜索尺度

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

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

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

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

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

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

批归一化(Batch Normalization)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

直观理解批归一化的作用

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

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

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

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

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

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

测试时的批归一化

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

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

Softmax 与多分类问题

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

编程框架

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

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

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

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

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

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

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

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

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

总结

这堂课的知识点有:

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

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

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

学习提示

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

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

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

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

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

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

课堂笔记

分批梯度下降

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

使用mini-batch

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

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

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

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

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

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

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

变成

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

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

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

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

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

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

选择批次大小

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

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

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

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

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

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

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

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

一段数据的平均值

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

背景

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

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

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

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

移动平均数

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

指数加权移动平均

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

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

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

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

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

偏差矫正

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

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

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

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

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

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

Momentum

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

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

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

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

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

RMSProp 和 Adam

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

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

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

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

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

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

最后,进行参数的更新:

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

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

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

学习率衰减

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

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

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

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

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

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

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

局部最优值

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

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

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

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

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

总结

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

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

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

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

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

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

代码实战

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

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

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

小猫分类项目框架

数据集

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

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

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

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

模型类

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

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

def __init__(self):
pass

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

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

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

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

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

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

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

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

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

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

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

1
2
3
4
class DeepNetwork(BaseRegressionModel):

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

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

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

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

优化器类

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

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

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

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

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

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

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

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

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

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

@abc.abstractmethod
def step(self):
pass

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

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

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

模型训练

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

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

# Prepare mini_batch
...

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

currrent_epoch = optimizer.epoch

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

optimizer.increase_epoch()

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

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

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

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

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

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

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

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

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

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

optimizer.increase_epoch()

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

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

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

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

使用 Mini-batch

注意 I/O 开销!

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

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

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

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

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

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

mini-batch 预处理

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

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

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

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

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

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

mini-batch 训练

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

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

mini-batch 的损失函数曲线

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

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

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

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

实现高级优化算法

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

Momentum

Momentum的主要实现代码如下:

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

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

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

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

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

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

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

RMSProp

RMSProp的主要实现代码如下:

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

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

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

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

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

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

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

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

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

Adam

Adam 的主要实现代码如下:

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

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

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

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

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

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

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

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

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

学习率衰减

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

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

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

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

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

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

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

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

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

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

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

return scheduler

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

实验结果

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

以下是实验的结果:

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

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

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

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

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

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

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

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

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

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

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

感想

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

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

学习提示

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

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

之后,我们会针对“高方差问题”,学习一系列解决此问题的方法。这些方法成为“正则化方法”。这周介绍的正则化方法有:添加正则化项、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

在上节课中,我们学习了逻辑回归——一种经典的学习算法。我兴致勃勃地用它训练了一个猫狗分类模型,结果只得到了57%这么个惨淡的准确率。正好,这周开始学习如何实现更复杂的模型了。这次,我一定要一雪前耻!

开始学这周的课之前,先回忆一下上周我们学习了什么。

对于一个神经网络,我们要定义它的网络结构(一个数学公式),定义损失函数。根据损失函数和网络结构,我们可以对网络的参数求导,并用梯度下降法最小化损失函数。

也就是说,不管是什么神经网络,都由以下几部分组成:

  • 网络结构
  • 损失函数
  • 优化策略

而在编程实现神经网络时,我们不仅要用计算机语言定义上面这几项内容,还需要收集数据预处理数据

在这堂课中,我们要学一个更复杂的模型,其知识点逃不出上面这些范围。在之后的学习中我们还会看到,浅层神经网络的损失函数优化策略和上节课的逻辑回归几乎是一模一样的。我们要关心的,主要是网络结构上的变化。

在学习之前,我们可以先有一个心理准备,知道大概要学到哪些东西。

课堂笔记

神经网络概述与符号标记

上节课我们使用的逻辑回归过于简单,它只能被视为只有一个神经元(计算单元)的神经网络。如上图第一行所示。

一般情况下,神经网络都是由许多神经元组成的。我们把一次性计算的神经元都算作“一层”。比如上图第二行的网络有两层,第一层有3个神经元,第二层有1个神经元。

上节课中,对于一个样本$x$,一层的神经网络是用下面的公式计算的:

而这节课将使用的两层神经网络,也使用类似的公式计算:

上节课中,参数$w$是一个列向量。这节课的参数$W$是一个矩阵。我们稍后会见到$W$的全貌。

这里的方括号上标$[l]$表示第$l$层相关的变量。总结一下,$a_i^{j}$表示第$k$个样本在网络第$j$层中向量的第$i$个分量。

事实上,输入$x$可以看成$a^{[0]}$。

这里的a是activation(激活)意思,每个$a$都是激活函数的输出。

为了方便称呼,我们给神经网络的层取了些名字:

其中,输入叫做“输入层”,最后一个计算层叫做“输出层”,中间其余的层都叫做“隐藏层”。事实上,由于第一个输入层不参与计算,它不会计入网络的总层数,只是为了方便称呼才这么叫。因此,上面这个网络看上去有3层,但叫做“双层神经网络”,或“单隐藏层神经网络”。

单样本多神经元的计算

让我们先看一下,对于一个输入样本$x^{(1)}$,神经网络是怎么计算输出的。

如图,输入 $x$ 是一个形状为$3 \times 1$的列向量。第一层有三个神经元,第一个神经元的参数是$w_1^{[1]}, b_1^{[1]}$,第二个是$w_2^{[1]}, b_2^{[1]}$,第三个是$w_3^{[1]}, b_3^{[1]}$。

$w_i^{[1]}$的形状是$1 \times 3$,$b_i^{[1]}$是常数。

每个神经元的计算公式和上节课的逻辑回归相同,都是$z_i^{[1]}=w_i^{[1]}x+b_i^{[1]}$,$a_i^{[1]}=\sigma(z_i^{[1]})$($i \in [1, 2, 3]$)。

回忆一下,上一节课里$w$的形状是$n_x \times1$,即一个长度为$n_x$的列向量,其中$n_x$是输入向量的长度(此处为3)。$b$是一个常数。计算结果时,我们要把$w$转置,计算$w^Tx+b$。这里的$w_i^{[1]}$是一个行向量,其形状是$1 \times n_x$,计算时不用转置。计算时直接$w_i^{[1]}x+b_i^{[1]}$就行。

因为有三个神经元,我们得到三个计算结果$a_1^{[1]}, a_2^{[1]}, a_3^{[1]}$。我们可以把它们合起来当成一个$3 \times 1$的列向量$a^{[1]}$,就像输入$x$一样。

之后,这三个输出作为输入传入第二层的神经元,计算$z^{[2]}=W_1^{[2]}a^{[1]}+b^{[2]}$, $\hat{y}=a^{[2]}=\sigma(z^{[2]})$。这个算式和上周的逻辑回归一模一样。

总结一下,如果某一层有$n$个神经元,那么这一层的输出就是一个长度为$n$的列向量。这个输出会被当作下一层的输入。神经网络的每一层都按同样的方式计算着。

对于单隐层神经网络,隐藏层的参数$W^{[1]}$的形状是$n_1 \times n_x$,其中$n_1$是隐藏层神经元个数,$n_x$是每个输入样本的向量长度。参数$b^{[1]}$的形状是$n_1 \times 1$。输出层参数$W^{[2]}$的形状是$1 \times n_1$,$b^{[2]}$的形状是$1 \times 1$。

多样本多神经元的计算

和上一节课一样,让我们把一个输入样本拓展到多个样本,看看整个计算公式该怎么写。

对于第$i$个输入样本$x^{(i)}$,我们要计算:

直接写的话,我们要写个for循环,把$i$从$0$遍历到$m-1$。

回忆一下,$m$是样本总数。

但是,如果把输入打包在一起,形成一个$n_x \times m$的矩阵$X$,那么整个计算过程可以用十分相似的向量化计算公式表示:

这里的$X$,$A$相当于横向“拉长了”:

激活函数

在神经网络中,我们每做完一个线性运算$Z=WX+b$后,都会做一个$\sigma(Z)$的操作。上周我们讲这个$\sigma$(sigmoid函数)是为了把实数的输入映射到$[0, 1]$。这是它在逻辑回归的作用。而在普通的神经网络中,$\sigma$就有别的作用了——激活线性输出。$\sigma$其实只是激活输出的激活函数的一员,还有很多其他函数都可以用作为激活函数。我们现在暂时不管这个“激活”是什么意思,先认识一下常见的激活函数。

画这些函数的代码见后文。

它们的数学公式如下:

其中leaky_relu里的$k$是一个常数,这个常数要小于1。图中的leaky_relu的$k$取了0.1。

现在来介绍一下这些激活函数。

sigmiod,老熟人了,这个函数可以把实数上的输入映射到$(0, 1)$。tanh其实是sigmoid的一个“位移版”(二者的核心都是$e^x$),它可以把实数的输入映射到$(-1, 1)$。

这两个函数有一个问题:当x极大或者极小的时候,函数的梯度几乎为0。从图像上来看,也就是越靠近左边或者右边,函数曲线就越平。梯度过小,会导致梯度下降法每次更新的幅度较小,从而使网络训练速度变慢。

为了解决梯度变小的问题,研究者们又提出了relu函数(rectified linear unit, 线性整流单元)。别看这个名字很高大上,relu函数本身其实很简单:你是正数,就取原来的值;你是负数,就取0。非常的简单直接。把这个函数用作激活函数,梯度总是不会太小,可以有效加快训练速度。

有人觉得relu对负数太“一刀切”了,把relu在负数上的值改成了一个随输入$x$变化的,十分接近0的值。这样一个新的relu函数就叫做leaky relu。(大家应该知道为什么leaky_relu的$k$要小于1了吧)

写在博客里的题外话:浅谈文章的统一性。为什么这里relu用的是小写呢?按照英文的写法,应该是ReLU才对啊?这里是不是写文章的时候不够严谨啊?其实不是。我们这里其实统一用的是代码写法,即全部单词小写。我们首次介绍relu时,是在上文的图片和公式里。那里面用的是小写的relu。后文其实是对这种描述的一个统一,表示“前文用到的relu”,而不是一般用语中的ReLU。在后面的文章中,我会使用ReLU这个称呼。

如果有严谨的文字工作者,还会质疑道:“你这篇文章里有些单词应该用公式框起来,有些应该用代码框起来,怎么直接用文本表示啊?”这是因为微信公众号对公式的支持很烂,我编辑得累死了,不想动脑去思考到底用公式还是用代码了。要把一个东西写得天衣无缝,需要耗费大量的时间。为了权衡,我抛弃了部分严谨性,换来了写文章的效率。

如何选择激活函数

tanh由于其值域比sigmoid大,原理又一模一样,所以tanh在数学上严格优于sigmoid。除非是输出恰好处于$(0, 1)$(比如逻辑回归的输出),不然宁可用tanh也不要用sigmoid。

现在大家都默认使用relu作为激活函数,偶尔也有使用leaky_relu的。吴恩达老师鼓励大家多多尝试不同的激活函数。

在之前介绍的公式中,我们所有激活函数$g$都默认用的是$g=\sigma$。准确来说,单隐层神经网络公式应该写成下面这种形式:

由于第二层网络的输出落在[0, 1],我们第二个激活函数还是可以用sigmoid,即$g^{[2]}=\sigma$。

激活函数的作用

假设我们有一个两层神经网络:

其中激活函数用$g$表示。

假如我们不使用激活函数,即令$g(x)=x$的话,这个神经网络就变成了:

我们把$W_2W_1$看成一个新的“$W$”,$(b_1+b_2)$看成一个新的”$b$”,那么这其实是一个单层神经网络。

也就是说,如果我们不用激活函数,那么无论神经网络有多少层,这个神经网络都等价于只有一层。这种神经网络永远只能拟合一个线性函数。

为了让神经网络取拟合一个非线性的,超级复杂的函数,我们必须要使用激活函数。

激活函数的导数(选读)

为了让大家重新体验一下高中学数学的感觉,这里求导的步骤推得十分详细。

sigmoid

上篇笔记也吐槽过了,想写出最后一步,需要发动数学家的固有技能:「注意到」。这不怎么学数学的人谁能注意到最后这一步啊。

tanh

回忆一下,$(\frac{u}{v})’=(\frac{u’v-uv’}{v^2})$。

最后这步我依然注意不到。我猜原函数$f(x)$是用$f’(x)=(1+ f(x))(1-f(x))$这个微分方程构造出来的,而不是反过来恰好发现导数能够写得这么简单。

relu

这个导求得神清气爽。

leaky relu

学数学的人可能会很在意:relu和leaky relu在0处没有导数啊!碰到0你怎么梯度下降啊?实际上,我们编程的时候,不用管那么多,直接也令0处的导数为1就行(即导数在0处的右极限)。

对神经网络做梯度下降

回顾一下,如果只有两个参数$w, b$,应该用下式做梯度下降:

回忆一下,$\alpha$是学习率,表示梯度更新的速度,一般取$0.0001$这种很小的值。

现在,我们有4个参数:$W^{[1]},W^{[2]}, b^{[1]},b^{[2]}$,它们也应该按照同样的规则执行梯度下降:

剩下的问题就是怎么求导了。让我们再看一遍神经网络正向传播的公式:

由于我们令$g^{[2]}=\sigma$,所以神经网络第二层(输出层)的导数可以直接套用上周的导数公式:

注意! 上周我们算的是$AdZ^T$,这周是$dZ^{[2]}A^{[1]T}$。这是因为参数$W$转置了一下。上周的$w$是列向量,这周每个神经元的权重$W_i$是行向量。

之后,我们来看第一层。首先求$dZ^{[1]}$:

注意,上式中右边第一项$dA^{[1]}$是$\frac{dJ}{dA^{[1]}}$的简写,第二项$\frac{dA^{[1]}}{dZ^{[1]}}$是实实在在的求导。

这里$dA^{[1]}$和$dW^{[2]}$的计算是对称的哟。

之后的$dW^{[1]}, db^{[1]}$的公式和前面$dW^{[2]}, db^{[2]}$的相同:

别忘了,$X=A^{[0]}$。

这些求导的步骤写成代码如下:

1
2
3
4
5
6
dZ2=A2-Y
dW2=np.dot(dZ2, A1.T) / m
db2=np.sum(dZ2, axis=1, keepdims=True) / m
dZ1=np.dot(W2.T, dZ2) * g1_backward(Z1)
dW1=np.dot(dZ1, X.T) / m
db1=np.sum(dZ1, axis=1, keepdims=True) / m

再次温馨提示,搞不清楚数学公式的细节没关系,直接拿来用就好了。要学会的是算法的整体思路。

这段代码有一点需要注意:

1
2
db2=np.sum(dZ2, axis=1, keepdims=True)
db1=np.sum(dZ1, axis=1, keepdims=True)

这个keepdims=True是必不可少的。使用np.sum, np.mean这种会导致维度变少的计算时,如果加了keepdims=True,会让变少的那一个维度保持长度1.比如一个[4, 3]的矩阵,我们对第二维做求和,理论上得到的是一个[4]的向量。但如果用了keepdims=True,就会得到一个[4, 1]的矩阵。

保持向量的维度,可以让某些广播运算正确进行。比如我要用[4, 3]的矩阵减去[4]的矩阵就会报错,而减去[4, 1]的矩阵就不会报错。

参数随机初始化

再次回顾下,梯度下降算法的结构如下:

1
2
3
4
初始化参数
迭代 k 步:
算参数的梯度
用梯度更新参数

对于这节课新学的单隐层神经网络,求导、更新参数的过程我们已经学完了。我们还有一个东西没有详细探究:参数的初始化方式。现在,我们来详细研究一下参数初始化。

在上节课中,我们用一句话就带过了参数初始化方法:令参数全为0就行了。这种初始化方法在这节课还有用吗?让我们来看课堂里提到的一个示例:

如上图,对于输入长度为2,第一层有2个神经元的网络,其第一层参数$W^{[1]}$为[[0, 0], [0, 0]]。这样算出来的神经元输出$a^{[1]}_1,a^{[1]}_2$是一样的。而更新梯度时,每一个神经元的参数$W^{[1]}_1, W^{[1]}_2$的梯度都只和该神经元的输出有关。这样,每个神经元参数的导数dw都是一模一样的。导数一样,初始化的值也一样,那么每个神经元的参数的值会一直保持相同。这样,不论我们在某一层使用了多少个神经元,都等价于只使用一个神经元。

为了不发生这样的情况,我们需要让每一个神经元的参数$w$都取不同的值。这可以通过随机初始化实现。只需要使用下面的代码就可以随机初始化$w$:

1
w = np.random.randn((h, w)) * 0.01

注意,这里我们给随机出的数乘了个0.01。这是因为出于经验,人们更倾向于使用更小的参数,以计算出更小的结果,防止激活函数(如tanh)在绝对值过大时梯度过小的问题。

后面的课会详细介绍该如何初始化这些参数,以及初始化参数可以解决哪些问题。

而$b$和之前一样,直接用0初始化就行了。

知识总结

在这堂课中,我们正式认识了神经网络的定义。原来,上周的逻辑回归只是一个特殊的神经网络。它只有一个输出层,并且使用sigmoid作激活函数。而这周,我们学习了如何定义一个两层(一个隐藏层、一个输出层)的神经网络,并且知道如何在网络中使用不同的激活函数。

让我们来看一下这节课的知识点:

  • 神经网络的定义
    • 输入层、隐藏层、输出层
    • 每一层每一个神经元相关的参数该怎么表示
  • 神经网络的计算方式
    • 单样本 -> 多样本
    • 正向传播与反向传播
  • 激活函数
    • 直观认识激活函数——激活函数属于神经网络计算中的哪一部分?
    • 常见的四种激活函数:sigmoid, tanh, relu, leaky_relu
    • 如何选择激活函数
    • 为什么要使用激活函数
  • 神经网络与逻辑回归的区别——参数初始化问题
    • 为什么不能用0初始化$W$
    • 随机初始化$W$
    • 可以用0初始化$b$

代码实战

这节课的编程作业是搞一个点集分类器。此任务的数据集如下图所示:

在平面上,已知有一堆红色的点和绿色的点。我们希望任意给定一个点,程序能够判断这个点是红点还是绿点。

让我们人类来分类的话,肯定会认为左边一片花瓣和右上角两片花瓣是绿色的,剩下三片花瓣是红色的(有部分点不满足这个规律,可以认为这些点是噪声,即不正确的数据)。让神经网络来做这个任务,会得到怎样的结果呢?

现在,让我们用这周学的单隐层神经网络,来实现这个分类器。

虽然前面说这周要继续挑战猫狗分类任务,但我估摸着这周的模型可能还是简单了一点。等下周学了再强大一点的模型,我再来复仇。

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

通用分类器类

在上节课的编程实战中,我们很暴力地写了“一摊”代码。说实话,有编程洁癖的我是不能接受那种潦草的代码的。如果代码写得太乱,就根本不能复用,根本不可读,根本不能体现编程的逻辑之美。

这周,我将解除封印,释放我30%的编程水平,展示一个比较优雅的通用分类器类该怎么写。我们会先把上周的逻辑回归用继承基类的方式实现一遍,再实现一遍这周的浅层神经网络。

分类器基类的代码如下:

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
import abc
import numpy as np

class BaseRegressionModel(metaclass=abc.ABCMeta):
# Use Cross Entropy as the cost function

def __init__(self):
pass

@abc.abstractmethod
def forward(self, X, train_mode=True):
# if self.train_mode:
# forward_train()
# else:
# forward_test()
pass

@abc.abstractmethod
def backward(self, Y):
pass

@abc.abstractmethod
def gradient_descent(self, learning_rate=0.001):
pass

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

def evaluate(self, X, Y):
Y_hat = self.forward(X, train_mode=False)
predicts = np.where(Y_hat > 0.5, 1, 0)
score = np.mean(np.where(predicts == Y, 1, 0))
print(f'Accuracy: {score}')

为了简化代码,我们用BaseRegressionModel表示一个使用交叉熵为损失函数的二分类模型。这样,我们所有的模型都可以共用一套损失函数loss、一套评估方法evaluate。这里损失函数和评估方法的实现都是从上周的代码里复制过来的。

让我们分别看一下其他几个类方法的描述:

  • __init__: 模型的参数应该在__init__方法里初始化。
  • forward:正向传播函数。这个函数即可以用于测试,也可以用于训练。如果是用于训练,就要令参数train_mode=True。为什么要区分训练和测试呢?这是因为,正向传播在训练的时候需要额外保存一些数据(缓存),保存数据是存在开销的。在测试的时候,我们可以不做缓存,以降低程序运行开销。
  • backward:反向传播函数。这个函数用于forward之后的梯度计算。算出来的梯度会缓存起来,供反向传播使用。
  • gradient_descent:用梯度下降更新模型的参数。(一般框架会把优化器和模型分开写。由于我们现在只学了梯度下降这一种优化策略,所以直接把梯度下降当成了模型类的方法)

有了这样一个分类器基类后,我们可以用统一的方式训练模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def train_model(model: BaseRegressionModel,
X_train,
Y_train,
X_test,
Y_test,
steps=1000,
learning_rate=0.001,
print_interval=100):
for step in range(steps):
Y_hat = model.forward(X_train)
model.backward(Y_train)
model.gradient_descent(learning_rate)
if step % print_interval == 0:
train_loss = model.loss(Y_hat, Y_train)
print(f'Step {step}')
print(f'Train loss: {train_loss}')
model.evaluate(X_test, Y_test)

有了一个初始化好的模型model后,我们在训练函数train_model里可以直接开始循环训练模型。每次我们先调用model.forward做正向传播,缓存一些数据,再调用model.backward反向传播算梯度,最后调用model.gradient_descent更新模型的参数。每训练一定的步数,我们监控一次模型的训练情况,输出模型的训练loss和测试精度。

看吧,是不是使用了类来实现神经网络后,整个代码清爽而整洁?

工具函数

1
2
3
4
5
6
7
8
9
10
import numpy as np

def sigmoid(x):
return 1 / (1 + np.exp(-x))

def relu(x):
return np.maximum(x, 0)

def relu_de(x):
return np.where(x > 0, 1, 0)

同样,为了让代码更整洁,我把一些工具函数单独放到了一个文件里。现在,如上面的代码所示,我们的工具函数只有几个损失函数及它们的导数。(用于sigmoid只用于最后一层,我们可以直接用dZ=A-Y跳一个导数计算步骤,所以这里没有写sigmoid的导数)。

复现逻辑回归

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
class LogisticRegression(BaseRegressionModel):

def __init__(self, n_x):
super().__init__()
self.n_x = n_x
self.w = np.zeros((n_x, 1))
self.b = 0

def forward(self, X, train_mode=True):
Z = np.dot(self.w.T, X) + self.b
A = sigmoid(Z) # hat_Y = A
if train_mode:
self.m_cache = X.shape[1]
self.X_cache = X
self.A_cache = A
return A

def backward(self, Y):
d_Z = self.A_cache - Y
d_w = np.dot(self.X_cache, d_Z.T) / self.m_cache
d_b = np.mean(d_Z)
self.d_w_cache = d_w
self.d_b_cache = d_b

def gradient_descent(self, learning_rate=0.001):
self.w -= learning_rate * self.d_w_cache
self.b -= learning_rate * self.d_b_cache

逻辑回归是上节课的内容,这里就不讲解,直接贴代码了。大家可以通过这个例子看一看BaseRegressionModel的子类应该怎么写。

实现单隐层神经网络

有了基类后,我们更加明确代码中哪些地方是要重新写,不能复用以前的代码了。在实现浅层神经网络时,我们要重写模型初始化正向传播反向传播梯度下降这几个步骤。

模型初始化

我们要在__init__里初始化模型的参数。回忆一下这周的单隐层神经网络推理公式:

其中,有四个参数$W^{[1]}, W^{[2]}, b^{[1]}, b^{[2]}$,它们的形状分别是$n_1 \times n_x$, $1 \times n_1$, $n_1 \times 1$, $1 \times 1$。我们需要在这里决定$n_x, n_1$这两个数。

$n_x$由输入向量的长度决定。由于我们是做2维平面点集分类,每一个输入数据就是一个二维的点。因此,在稍后初始化模型时,我们会令$n_x=2$。

$n_1$属于网络的超参数,我们可以调整这个参数的值。

计划好了初始化函数的输入参数后,我们来看看初始化函数的代码:

1
2
3
4
5
6
7
8
def __init__(self, n_x, n_1):
super().__init__()
self.n_x = n_x
self.n_1 = n_1
self.W1 = np.random.randn(n_1, n_x) * 0.01
self.b1 = np.zeros((n_1, 1))
self.W2 = np.random.randn(1, n_1) * 0.01
self.b2 = np.zeros((1, 1))

别忘了,前面我们学过,初始化W时要使用随机初始化,且让初始化出来的值比较小。

正向传播

我们打算神经网络令第一层的激活函数为relu,第二层的激活函数为sigmoid。因此,模型的正向传播公式如下:

用代码表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
def forward(self, X, train_mode=True):
Z1 = np.dot(self.W1, X) + self.b1
A1 = relu(Z1)
Z2 = np.dot(self.W2, A1) + self.b2
A2 = sigmoid(Z2)
if train_mode:
self.m_cache = X.shape[1]
self.X_cache = X
self.Z1_cache = Z1
self.A1_cache = A1
self.A2_cache = A2
return A2

其中train_mode里的内容是我们待会儿要在反向传播用到的数据,这里需要先缓存起来。

事实上,我是边写反向传播函数,边写这里if train_mode:里面的缓存数据的。编程不一定要按照顺序写。

反向传播

翻译一下这些公式:

用代码写就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def backward(self, Y):
dZ2 = self.A2_cache - Y
dW2 = np.dot(dZ2, self.A1_cache.T) / self.m_cache
db2 = np.sum(dZ2, axis=1, keepdims=True) / self.m_cache
dA1 = np.dot(self.W2.T, dZ2)

dZ1 = dA1 * relu_de(self.Z1_cache)
dW1 = np.dot(dZ1, self.X_cache.T) / self.m_cache
db1 = np.sum(dZ1, axis=1, keepdims=True) / self.m_cache

self.dW2_cache = dW2
self.dW1_cache = dW1
self.db2_cache = db2
self.db1_cache = db1

算完梯度后,我们要把它们缓存起来,用于之后的梯度下降。

梯度下降

1
2
3
4
5
def gradient_descent(self, learning_rate=0.001):
self.W1 -= learning_rate * self.dW1_cache
self.b1 -= learning_rate * self.db1_cache
self.W2 -= learning_rate * self.dW2_cache
self.b2 -= learning_rate * self.db2_cache

梯度已经算好了,梯度下降就没什么好讲的了。

挑战点集分类问题

数据收集

这里我已经提前实现好了生成数据集的函数。本文的附录里会介绍这些函数的细节。

使用项目里的 generate_point_set 函数可以生成一个平面点集分类数据集:

1
2
3
4
x, y, label = generate_point_set()
# x: [240]
# y: [240]
# label: [240]

其中,x[i]是第i个点的横坐标,y[i]是第i个点的纵坐标,label[i]是第i个点的标签。标签为0表示是红色的点,标签为1表示是绿色的点。

数据预处理

得到了原始数据后,我们要把数据处理成矩阵X和Y,其中X的形状是[2, m],Y的形状是[1, m],其中m是样本大小。之后,我们还需要把原始数据拆分成训练集和测试集。

第一步生成矩阵的代码如下:

1
2
3
4
X = np.stack((x, y), axis=1)
Y = np.expand_dims(label, axis=1)
# X: [240, 2]
# Y: [240, 1]

大家应该能猜出stackexpand_dims是什么意思。stack能把两个张量堆起来,比如这里把表示x,y坐标的一维向量合成起来,变成一个向量(长度为2)的向量(长度为240)。expand_dims就是凭空给张量加一个长度为1的维度,比如这里给Y添加了axis=1上的维度。

第二步划分数据集的方法如下:

1
2
3
4
5
6
7
8
9
indices = np.random.permutation(X.shape[0])
X_train = X[indices[0:200], :].T
Y_train = Y[indices[0:200], :].T
X_test = X[indices[200:], :].T
Y_test = Y[indices[200:], :].T
# X_train: [2, 200]
# Y_train: [1, 200]
# X_test: [2, 40]
# Y_test: [1, 40]

注意,我们划分数据集的时候最好要随机划分。我这里使用np.random.permutation生成了一个排列,把这个排列作为下标来打乱数据集。

大家看不懂这段代码的话,可以想象这样一个例子:老师想抽10个人去值日,于是,他把班上同学的学号打乱,在打乱后的学号列表中,把前10个学号的同学叫了出来。代码里indices就是用随机排列生成的一个“打乱过的学号”,根据这个随机索引值,我们把前200个索引的数据当成训练集,200号索引之后的数据当成测试集。

经过这些处理,数据就符合课堂上讲过的形状要求了。

使用模型

1
2
3
4
5
6
7
8
9
10
n_x = 2

model1 = LogisticRegression(n_x)
model2 = ShallowNetwork(n_x, 2)
model3 = ShallowNetwork(n_x, 4)
model4 = ShallowNetwork(n_x, 10)
train_model(model1, X_train, Y_train, X_test, Y_test, 500, 0.0001, 50)
train_model(model2, X_train, Y_train, X_test, Y_test, 2000, 0.01, 100)
train_model(model3, X_train, Y_train, X_test, Y_test, 2000, 0.01, 100)
train_model(model4, X_train, Y_train, X_test, Y_test, 2000, 0.01, 100)

由于我们前面已经定义好了模型,使用模型的过程就很惬意了。这里直接初始化我们自己编写的类,再用训练函数训练模型即可。

为了比较不同的模型,从感性上认识不同模型间的区别,在示例代码中我训练了4个模型。第一个模型是逻辑回归,后三个模型分别是隐藏层有2、4、10个神经元的单隐藏层神经网络。

模仿这堂课的编程作业,我也贴心地实现了模型可视化函数:

1
2
3
visualize_X = generate_plot_set()
plot_result = model4.forward(visualize_X, train_mode=False)
visualize(X, Y, plot_result)

只要运行上面这些代码,大家就可以看到模型具体是怎么分类2维平面上所有点的。让我们在下一节里看看这些函数的运行效果。

实验报告

好了,最好玩的地方来了。让我们有请四位选手,看看他们在二维点分类任务上表现如何。

首先是逻辑回归:

逻辑回归选手也太菜了吧!他只能模拟一条直线。这条直线虽然把下面两片红色花瓣包进去了,但忽略了左上角的花瓣。太弱了,太弱了!

隐藏层只有2个神经元的选手也菜得不行,和逻辑回归一起可谓是“卧龙凤雏”啊!

4-神经元选手似乎在尝试做出一些改变!好像有一次的运行结果还挺不错!但怎么我感觉他的发挥不是很稳定啊?他是在瞎蒙吧?

好,那我们最后上场的是4号选手10-神经元网络。4号选手可谓是受到万众的期待啊。据说,他有着“二维点分类小丸子”的称号,让我们来看一看他的表现:

只见4号网络手起刀落,刀刀见血。不论是怎么运行程序,他都能精准无误地把点集正确分类。我宣布,他就是本届点集分类大赛的冠军!让我们祝贺他!

程序里很多超参数是可调的,数据集也是可以随意修改的。欢迎大家去使用本课的代码,比较一下不同的神经网络。

总结

通过这节课的编程练习后,大家应该掌握以下编程技能:

  • 编写单隐层神经网络的正向传播
  • 编写单隐层神经网络的反向传播
  • 正确初始化神经网络的参数
  • 常见激活函数及其导数的实现

此外,通过浏览我的项目,大家应该能够提前学到以下技能:

  • 在神经网络中使用缓存的方法保存数据

当然,我相信我的项目里还展示了许多编程技术。这些技能严格来说不在本课程的要求范围内,大家可以自行体悟。

附赠内容

如何画激活函数

1
2
import matplotlib.pyplot as plt
import numpy as np

先导入第三方库。

1
2
3
4
5
6
7
8
9
10
11
def sigmoid(x):
return 1 / (1 + np.exp(-x))

def tanh(x):
return (np.exp(x) - np.exp(-x)) / (np.exp(x) + np.exp(-x))

def relu(x):
return np.maximum(x, 0)

def leaky_relu(x):
return np.maximum(x, 0.1 * x)

再定义好激活函数的公式。

1
2
3
4
5
x = np.linspace(-3, 3, 100)
y1 = sigmoid(x)
y2 = tanh(x)
y3 = relu(x)
y4 = leaky_relu(x)

画函数,其实就是生成函数上的一堆点,再把相邻的点用直线两两连接起来。为了生成函数上的点,我们先用np.linspace(-3, 3, 100)生成100个位于[-3, 3]上的x坐标值,用这些x坐标值算出每个函数的y坐标值。

1
2
3
4
5
plt.subplot(2, 2, 1)
plt.axvline(x=0, color='k')
plt.axhline(y=0, color='k')
plt.plot(x, y1)
plt.title('sigmoid')

之后就是调用API了。这里只展示一下sigmoid函数是怎么画出来的。plt.subplot(a, b, c)表示你要在一个a x b的网格里的第c个格子里画图。 plt.axvline(x=0, color='k') plt.axhline(y=0, color='k')用于生成x,y轴,plt.plot(x, y1)用于画函数曲线,plt.title('sigmoid')用于给图像写标题。

1
plt.show()

用类似的方法画完所有函数后,调用plt.show()把图片显示出来就大功告成了。

这段代码的链接:https://github.com/SingleZombie/DL-Demos/blob/master/dldemos/ShallowNetwork/plot_activation_func.py

学API本身没有任何技术含量,知道API能做什么,有需求的时候去查API用法即可。

画花

看完上面的内容,有些人肯定会想:“诶,你数据集里那朵花画得挺不错啊,你是不是学过美术的啊?”嘿嘿,你们能这么想,我很荣幸。其实那朵花是用程序生成出来的。作为笔记的赠品,我打算顺手介绍一下该怎么用高中知识画出前面的那朵花。

代码文件:dldemos/ShallowNetwork/genereate_points.py

流程一览

这幅图足以概括花朵绘制的流程。

  1. 生成半个椭圆。
  2. 合成完整的椭圆。
  3. 把椭圆移到x正半轴。
  4. 复制、旋转椭圆。

有人会说:“这前三步可以用一步就完成吧?你直接生成一个在x正半轴上的椭圆就好了,干嘛要拆开来?”别急,看了后文你就知道了。我这么做,完全是为了多展示一点知识,可谓是用心良苦啊。

画半个椭圆

椭圆的公式是$\frac{x^2}{a^2}+\frac{y^2}{b^2}=1$,其中$a$是椭圆在x轴上的轴长,$b$是在y轴上的轴长。我画的椭圆的长轴为20,短轴为10,其形状和公式如图所示。

但程序可不认得这个公式。为了生成椭圆上的点,我们可以遍历横坐标x,用公式$y=b\sqrt{1-\frac{x^2}{a^2}}$算出对应的y坐标。

这段生成半椭圆的代码如下所示:

1
2
3
4
5
6
def half_oval(cnt, h=10, w=20):
x = np.linspace(-w, w, cnt)
y = np.sqrt(h * h * (1 - x * x / w / w))
return np.stack((x, y), 1)

petal1 = half_oval(20)

hafl_ovel的参数分别表示椭圆上点的数量、y轴上轴长、x轴上轴长。根据刚刚的理论分析,我们在第二、三行算出所有点的x, y坐标。第四行用np.stack((x, y), 1)把坐标合并起来。

这里要介绍一下stack函数的用法。stack用于把多个张量(第一个参数)按某一维(第二个参数)堆叠起来。第一个参数很好理解,而第二个参数“堆叠的维度”就不是那么好理解了。让我们针对这份代码,看两个取不同维度的例子。

在我们这份代码中,执行完第二、三行后,x[x1, x2 ..., xn]这样一个形状为[n]的向量,y也是[y1, y2 ..., yn]这样一个形状为[n]的向量。

当堆叠维度取0时,x会变成[[x1, x2, ..., xn]]([1, n])的矩阵,y会变成[[y1, y2, ..., yn]]([1, n])的矩阵。之后,两个矩阵的第一维会拼起来,变成[[x1, x2, ..., xn], [y1, y2, ..., yn]]这样一个形状为[2, n]的矩阵。

当堆叠维度取1时,x会变成[[x1], [x2], ..., [xn]]([n, 1])的矩阵,y会变成[[y1], [y2], ..., [yn]]([n, 1])的矩阵。之后,两个矩阵的第二维会拼起来,变成[[x1, y1], [x2, y2]..., [xn, yn]]这样一个形状为[n, 2]的矩阵。

我们希望生成一个坐标的数组,即形状为[n 2]的矩阵。因此,我们会堆叠维度1(第二个维度),即使用如下代码:

1
np.stack((x, y), 1)

总之,经过以上操作,half_oval会返回一个形状为[n, 2]的坐标数组,表示半个花瓣上每个点的坐标。

翻转合并椭圆

要把半椭圆垂直翻转,实际上只要令半椭圆上所有点的y坐标取反即可:

但是,这种写法不够高级。我们可以写成矩阵乘法的形式:

如果你对矩阵乘法不熟,只需要知道

设翻转矩阵为$F$,坐标向量为$p$,则翻转后的向量$p’$可以写成:

这里我们默认$p$和$p’$都是列向量。但是,刚刚我们生成点的坐标时,每个坐标都是一个行向量。也就是说,$p$和$p’$其实都是行向量。因此,上式应该改成:

最后我们要算的是$p’$,因此可以对上式两边再取转置:

有了这些数学上的分析,我们可以写代码了。

首先是生成翻转矩阵:

1
2
def vertical_flip():
return np.array([[1, 0], [0, -1]])

之后生成翻转后的花瓣:

1
petal2 = np.dot(half_oval(20), vertical_flip().T)

现在,我们有开始得到的petal1和翻转后的petal2,它们的形状都是[n, 2]。我们希望把这两个坐标数组合并起来。这可以通过下面这行代码实现:

1
petal = np.concatenate((petal1, petal2), 0)

concatenate用于按某一维(第二个参数)拼接张量(第一个参数)。回顾一下,刚刚的半椭圆张量的形状[n, 2]表示有n2维坐标。合并两个半椭圆后,我们应该得到2n个点,即得到一个形状为[2n, 2]的张量。因此,这里我们要把两个半椭圆数组按第一维(0号维度)拼接。

concatenate和刚刚提到的stack有点像。其实,stack就是新建了一个维度,再做concatenate操作。stack一般由于把单独计算出来的x, y, z这样的坐标堆叠成一个坐标数组/坐标张量,concatenate一般用于合并多个性质一样的张量,比如这里的合并两个坐标数组。

移动椭圆

移动椭圆很简单,只要给所有坐标加同一个向量就行了:

1
petal += [25, 0]

注意,这里的petal是一个形状为[2n, 2]的张量,而[25, 0]是一个形状为[2]的张量。这一个逐元素的加法操作之所以能够被程序正常解读,是因为上周提到的“广播”操作。通过使用广播,[25, 0]这个向量被加到了坐标数组中的每一个坐标里。

旋转花瓣,生成花朵

如上图1所示,一个坐标$(x, y)$可以用它到原点的距离$r$和与x正半轴夹角$\theta$表示:

那么,如上图2所示,假设现在把一个夹角为$\theta$的$(x_1, y_1)$旋转$\alpha$后得到了$(x_2, y_2)$,$(x_2, y_2)$可以表示为:

但是,我们现在只知道$(x_1, y_1)$这个坐标。给定$x_1, y_1, \alpha$,该怎么计算出$x_2, y_2$呢?

这里,我们可以用高中学过的三角函数两角和公式,把刚才那个三角函数“拆开”:

我们又已知:

因此,$x_2, y_2$可以用下面的式子表示:

这个式子用矩阵乘法表达如下:

也就是说,旋转操作也可以用一个矩阵表示。我们可以用和刚刚做翻转操作相同的办法,对坐标数组做旋转。以下是代码实现:

1
2
3
def rotate(theta):
return np.array([[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]])

这个函数可以生成一个让坐标旋转theta弧度的矩阵。

这样,我们如果想让一个坐标数组旋转60度,可以写下面的代码:

1
new_petal = np.dot(petal, rotate(np.radians(60)).T)

在生成花朵时,我们除了生成第一片花瓣外,还要通过旋转生成另外5朵花瓣,并把花瓣合并起来。这整个流程的代码如下:

1
2
3
4
5
6
7
8
petal1 = half_oval(20)
petal2 = np.dot(half_oval(20), vertical_flip().T)
petal = np.concatenate((petal1, petal2), 0)
petal += [25, 0]
flower = petal.copy()
for i in range(5):
new_petal = np.dot(petal.copy(), rotate(np.radians(60) * (i + 1)).T)
flower = np.concatenate((flower, new_petal), 0)

我们可以给每个坐标打上0或1的标签,0表示点是红色,1表示点是绿色。然后,我们把各个花瓣染成不同的颜色:

1
2
3
4
label = np.zeros([40 * 6])
label[0:40] = 1
label[40:80] = 1
label[120:160] = 1

再做一些操作就可以用matplotlib画出花朵了:

1
2
3
4
5
6
7
8
9
10
11
12
x = flower[:, 0]
y = flower[:, 1]

c = np.where(label == 0, 'r', 'g')

import matplotlib.pyplot as plt
plt.scatter(x, y, c=c)

plt.xlim(-50, 50)
plt.ylim(-50, 50)

plt.show()

在数据中加入噪声

大家可以发现,我生成的花朵数据中,有几个点的颜色“不太对劲”。这是为了模拟训练数据中的噪声数据。让我们看看这些噪声是怎么添加的。

为了让部分数据的标签出错,我们只需要随机挑选出一些数据,然后令它们的标签取反(0变1,1变0)即可。这里涉及一个问题:该怎样从n个数据中随机挑选出若干个数据呢?

在我项目中,我使用的方法如下:

1
2
3
4
5
from numpy.random import default_rng

rng = default_rng()
noise_indice1 = rng.choice(40 * 6, 10, replace=False)
label[noise_indice1] = 1 - label[noise_indice1]

生成随机数需要一个随机数生成器。这里我用rng = default_rng()生成了一个默认的随机数生成器,它从均匀分布生成随机数。

noise_indice1 = rng.choice(40 * 6, 10, replace=False)用于生成多个不重复的随机数。rng.choice的第一个参数40*6表示生成出来的随机数位于区间[1, 40*6]。第二个参数10表示生成10个随机数。replace=False表示生成的随机数不重复。

最后,我们用label[noise_indice1] = 1 - label[noise_indice1]把随机选中的标签取反。

感想

我很早之前就在计划如何构建我的个人IP。没想到,从上周日开始,我不知不觉地开始认真地在公开渠道上发文章了。

发完文章后,我其实抱有很大的期待,希望能有很多人来读我的文章(哪怕是早已养成了不以他人的评价来评价自己的我,也不能免俗)。很可惜,文章似乎并不是很受欢迎。

还好我有着强大的自信心,心态一点也不受影响。首先,我自己有着强大的鉴别能力,在我自己来看,我的文章水准不低;其次,我的部署教程经 OpenMMLab 发表,受到了不少赞誉,客观上证明我当前的写作水平很强。文章不受欢迎,肯定另有原因。

首先,是我现在没有曝光度。这是当然的,毕竟我之前一点名气也没有,平台并不会去推荐你的文章,能够接触到你文章的人本来就少。另外,我的文章十分冗长,用我自己的话来讲,“根本不是给人来看的”(本来写文章的目的就是为了总结我自己的学习心得,提升我的学习效果)。虽然认真读起来,其实还可以,但几乎没有人有足够的动力去把我这些文章认真读完。

这两个问题,我都会去想办法解决。曝光度的问题我已经想好了办法,在这里就不提了。而第二点,文章可读性这点,对现在的我来说非常好解决。说实话,我不是写不出大家很愿意去读的文章,而是不愿写。如果你想去迎合他人的体验,那你肯定要付出额外的心血。我现在的主业是学习,不是搞自媒体,我之前比较高傲,懒得去把文章写得更加适合大部分人群阅读。但是,现在,我生气了,我认真了,我很不服气。我不是做不好,而是没有去做。我一旦出手,必定是一鸣惊人。

从这周开始,我的博客只发笔记原稿。发到其他平台上时,我会做一定的修改,使之阅读体验更好。

最可怕的是,我还是不会花大量的时间去讨好读者,我还是会保证我的学习工作不受影响。我会拿出我的真实实力,真正的人性洞察能力,真正的时间分配能力,真正的权衡利弊的能力,以最高效率生产出质量优秀的文章。以我这些精心写作的博客原稿为基础,我有自信生产出大量有趣、有深度的文章。我靠这些文字火不起来,可以理解,因为认真愿意去学深度学习的人,没有那么多。但是,我有足够的信心,我认为我的文章一定会受到很多人的好评。

另外,我刚刚是承认我仅凭这些深度学习教程文章是火不起来的。但我并没有承认我的个人IP火不起来。究竟我之后还会干出哪些大事?我这里不讲,且看历史是怎么发展的。


嘿嘿嘿,为了准备之后的编程实况解说,我这一课的编程是一边在录制一边编的。结果我发挥超神,3小时左右就把这一课代码写完了,其中实现逻辑回归和通用分类器框架花了40分钟,实现神经网络花了20分钟,剩下时间都在捣鼓Numpy API,在可视化网络的输出结果。可以说我的编程水平相比普通人已经登峰造极了。但我还会继续精进我的编程技术,直至出神入化,神鬼莫及的境界。

顺带一提,第一次编写一个程序的直播是没有节目效果的。你大部分时间都会花在思考上,你脑子里想的东西是无法即时传递给观众的。哪怕是搞节目效果能力这么强的我,录出来的视频也不太好看。要做编程教学视频,必须要提前写一遍代码,第二次重新编同一段程序的时候,才有可能游刃有余地解说。

这堂课要学习的是逻辑回归——一种求解二分类任务的算法。同时,这堂课会补充实现逻辑回归必备的数学知识、编程知识。学完这堂课后,同学们应该能够用Python实现一个简单的小猫辨别器。

学习提示

如上图所示,深度学习和编程,本来就是相对独立的两块知识。

深度学习本身的知识包括数学原理和实验经验这两部分。深度学习最早来自于数学中的优化问题。随着其结构的复杂化,很多时候我们解释不清为什么某个模型性能更高,只能通过重复实验来验证模型的有效性。因此,深度学习很多情况下变成了一门“实验科学”。

深度学习中,只有少量和编程有关系的知识,比如向量化计算、自动求导器等。得益于活跃的开源社区,只要熟悉了这些少量的编程技巧,人人都可以完成简单的深度学习项目。但是,真正想要搭建一个实用的深度学习项目,需要完成大量“底层”的编程工作,要求开发者有着广泛的编程经验。

通过上吴恩达老师的课,我们应该能比较好地掌握深度学习的数学原理,并且了解深度学习中少量的编程知识。而广泛的编程经验、修改模型的经验,这些都是只上这门课学不到的。

获取修改模型的经验这项任务过于复杂,不太可能短期学会,几乎可以作为研究生的课题了。而相对而言,编程的经验就很好获得了。

我的系列笔记会补充很多编程实战项目,希望读者能够通过完成类似的编程项目,在学习课内知识之余,提升广义上的编程能力。比如在这周的课程里,我们会用课堂里学到的逻辑回归从头搭建一个分类器。

课堂笔记

本节课的目标

在这节课里,我们要完成一个二分类任务。所谓二分类任务,就是给一个问题,然后给出一个“是”或“否”的回答。比如给出一张照片,问照片里是否有一只猫。

这节课中,我们用到的方法是逻辑回归。逻辑回归可以看成是一个非常简单的神经网络。

符号标记

从这节课开始,我们会用到一套统一的符号标记:

$(x, y)$ 是一个训练样本。其中,$x$ 是一个长度为 $n_x$ 的一维向量,即 $x \in \mathcal{R}^{n_x}$。$y$ 是一个实数,取0或1,即$y \in \{0, 1\}$。取0表示问题的的答案为“否”,取1表示问题的答案为“是”。

这套课默认读者对统计机器学习有基本的认识,似乎没有过多介绍训练集是什么。在有监督统计机器学习中,会给出训练数据。训练数据中的每一条训练样本包含一个“问题”和“问题的答案”。神经网络根据输入的问题给出一个自己的解答,再和正确的答案对比,通过这样一个“学习”的过程来优化解答能力。

对计算机知识有所了解的人会知道,在计算机中,颜色主要是通过RGB(红绿蓝)三种颜色通道表示。每一种通道一般用长度8位的整数表示,即用一个0~255的数表示某颜色在红、绿、蓝上的深浅程度。这样,一个颜色就可以用一个长度为3的向量表示。一幅图像,其实就是许多颜色的集合,即许多长度为3的向量的集合。颜色通道,再算上某颜色所在像素的位置$(x, y)$,图像就可以看成一个3维张量$I \in \mathcal{R}^{H \times W \times 3}$,其中$H$是图像高度,$W$是图像宽度,$3$是图像的通道数。在把图像输入逻辑回归时,我们会把图像“拉直”成一个一维向量。这个向量就是前面提到的网络输入$x$,其中$x$的长度$n_x$满足$n_x = H \times W \times 3$。这里的“拉直”操作就是把张量里的数据按照顺序一个一个填入新的一维向量中。

其实向量就是一维的,但我还是很喜欢强调它是“一维”的。这是因为在计算机中所有数据都可以看成是数组(甚至C++的数组就叫vector)。二维数组不过是一维数组的数组,三位数组不过是二维数组的数组。在数学中,为了方便称呼,把一维数组叫“向量”,二维数组叫“矩阵”,三维及以上数组叫“张量”。其实在我看来它们之间只是一个维度的差别而已,叫“三维向量”、“一维张量”这种不是那么严谨的称呼也没什么问题。

实际上,我们有很多个训练样本。样本总数记为$m$。第$i$个训练样本叫做$(x^{(i)}, y^{(i)})$。在后面使用其他标记时,也会使用上标$(i)$表示第$i$个训练样本得到的计算结果。

所有输入数据的集合构成一个矩阵(其中每个输入样本用列向量的形式表示,这是为了方便计算机的计算):

同理,所有真值也构成集合 $Y$:

由于每个样本$y^{(i)}$是一个实数,所以集合$Y$是一个向量。

逻辑回归的公式描述

逻辑回归是一个学习算法,用于对真值只有0或1的“逻辑”问题进行建模。给定输入$x$,逻辑回归输出一个$\hat{y}$。这个$\hat{y}$是对真值$y$的一个估计,准确来说,它描述的是$y=1$的概率,即$\hat{y}=P(y=1 | x)$

逻辑回归会使用一个带参数的函数计算$\hat{y}$。这里的参数包括$w \in \mathcal{R}^{n_x}, b \in \mathcal{R}$。

说起用于拟合的函数,最容易想到的是线性函数$w^Tx+b$(即做点乘再加$b$: $w^Tx+b = (\Sigma_{i=1}^{n_x}w_ix_i)+b$ )。但线性函数的值域是$(- \infty,+\infty)$(即全体实数$\mathcal{R}$),概率的取值是$[0, 1]$。我们还需要一个定义域为$\mathcal{R}$,值域为$[0, 1]$,把线性函数映射到$[0, 1]$上的一个函数。

逻辑回归中,使用的映射函数是sigmoid函数$\sigma$,它的定义为:

这个函数可以有效地完成映射,它的函数图像长这个样子:

这里不用计较为什么使用这个函数,只需要知道这个函数的趋势:$x$越小,$\sigma (x)$越靠近0;$x$越大,$\sigma (x)$越靠近1。

也就是说,最终的逻辑回归公式长这个样子:$\hat{y} = \sigma(w^Tx+b)$。

逻辑回归的损失函数(Cost Function)

所有的机器学习问题本质上是一个优化问题,一般我们会定义一个损失函数(Cost Function),再通过优化参数来最小化这个损失函数。

回顾一下我们的任务目标:我们定义了逻辑回归公式$\hat{y} = \sigma(w^Tx+b)$,我们希望$\hat{y}$尽可能和$y$相近。这里的“相近”,就是我们的优化目标。损失函数,可以看成是$y, \hat{y}$间的“距离”。

逻辑回归中,定义了每个输出和真值的误差函数(Loss Function),这个误差函数叫交叉熵

不使用另一种常见的误差函数均方误差的原因是,交叉熵较均方误差梯度更加平滑,更容易在之后的优化中找到全局最优解。

误差函数是定义在每个样本上的,而损失函数是定义在整个样本上的,表示所有样本误差的“总和”。这个“总和”其实就是平均值,即损失函数$J(w, b)$为:

优化算法——梯度下降

有了优化目标,接下来的问题就是如何用优化算法求出最优值。这里使用的是梯度下降(Gradient Descent) 法。梯度下降的思想很符合直觉:如果要让函数值更小,就应该让函数的输入沿着函数值下降最快的方向(梯度的方向)移动。

以课件中的一元函数为例:

一元函数的梯度值就是导数值,方向只有正和负两个方向。我们要根据每个点的导数,让每个点向左或向右“运动”,以使函数值更小。

从图像里可以看出,如果是参数最开始在A点,则往右走函数值才会变少;反之,对于B点,则应该往左移动。

每个点都应该向最小值“一小步一小步”地移动,直至抵达最低点。为什么要“一小步”移动呢?可以想象,如果一次移动的“步伐”过大,改变参数不仅不会让优化函数变小,甚至会让待优化函数变大。比如从A点开始,同样是往右移动,如果“步伐”过大,A点就会迈过最低点的红点,甚至跑到B点的上面。那么这样下去,待优化函数会越来越大,优化就失败了。

为了让优化能顺利进行,梯度下降法使用学习率(Learning Rate) 来控制参数优化的“步伐”,即用如下方法更新损失函数$J(w)$的参数:

这里的 $\alpha$ 就是学习率,它控制了每次梯度更新的幅度。

其实这里还有两个问题:参数$w$该如何初始化;该执行梯度下降多少次。在这个问题中初始化对结果影响不大,可以简单地令$w=0$。而优化的次数没有硬性的需求,先执行若干次,根据误差是否收敛再决定是否继续优化即可。

前置知识补充

到这里,逻辑回归的知识已经讲完了。让我们梳理一下:

在逻辑回归问题中,我们有输入样本集$X$和其对应的期望输出$Y$,我们希望找到拟合函数$\hat{Y}=w^TX+b$,使得$\hat{Y}$和$Y$尽可能接近,即让损失函数$J(w, b)=mean(-(Ylog\hat{Y}+(1-Y)log(1-\hat{Y})))$尽可能小。

这里的$X,Y,\hat{Y}$表示的是全体样本。稍后我们会讨论如何用公式表示全体样本的计算。

我们可以用$0$来初始化所有待优化参数$w, b$,并执行梯度下降

若干次后得到一个较优的拟合函数。

为了让大家成功用代码实现逻辑回归,这门课贴心地给大家补充了数学知识和编程知识。

在我的笔记中,补充编程知识的记录会潦草一些。

求导

这部分对中国学生来说十分简单,因为求导公式是高中教材的内容。

导数即函数每时每刻的变化率,比如位移对时间的导数就是速度。以常见函数为例,对于直线$y=kx$,函数的变化率时时刻刻都是$k$;对于二次函数$y=x^2$,$x$处的导数是$2x$。

计算图

其实,所有复杂的数学运算都可以拆成计算图表示法。

计算中的”图”其实是一个计算机概念,表示由节点和边组成的集合。不熟悉的话,当成日常用语里的图来理解也无妨。

比如上图中,哪怕是简单的运算$2a+b$,也可以拆成两步:先算$2 \times a$,再算$(2a) + b$。

这里的“步”指原子运算,即最简单的运算。原子运算可以是加减乘除,也可以是求指数、求对数。复杂的运算,只是对简单运算的组合、嵌套。

明明简简单单可以用一行公式表示的事,要费很大的功夫画一张计算图呢?这是因为,对函数求导满足“链式法则”,借助计算图,可以更方便地用链式法则算出所有参数的导数。比如在上图中要求$f$对$a$的导数,使用链式法则的话,可以通过先求$f$对$c$的导数,再求$c$对$a$的导数得到。

利用计算图对逻辑回归求导

逻辑回归有计算图:

现在利用链式法则从右向左求导:

这些运算里最难“注意到”的是$\frac{e^{-z}}{(1+e^{-z})^2} = a(1-a)$。

在学计算机科学的知识时,可以适当忽略一些数学证明,把算好的公式直接拿来用,比如这里的$\frac{dL}{dz}=a-y$。

$\frac{dL}{dw_i}, \frac{dL}{db}$就是我们要的梯度了,用它们去更新原来的参数即可。值得一提的是,这里的梯度是对一个样本而言。对于全部$m$个样本来说,本轮的梯度应该是所有样本的梯度的平均值。后面我们会学习如何对所有样本求导。

Python 向量化计算

在刚刚的一轮迭代中,我们要用到两次循环:

  1. 对$m$个样本循环处理
  2. 对$n_x$个权重$w_i$与对应的$x_i$相乘

直接拿 Python 写这些 for 循环,程序会跑得很慢的,这里最好使用向量化计算。在这一节里我们补充一下 Python 基础知识,下一节介绍怎么用它们实现逻辑回归的向量化实现。

课程中提到向量化的好处是可以用SIMD(单指令多数据流)优化,这个概念可以理解成计算机会同时对16个或32个数做计算。如果输入的数据是向量的话,相比一个一个做for循环,一次算16,32个数的计算速度会更快。

但实际上,除了无法使用SIMD以外,Python的低效也是拖慢速度的原因之一。哪怕是不用SIMD,单纯地用C++的for循环实现向量化计算,都能比用Python的循环快上很多。

Python 的 numpy 库提供了向量化计算的接口。比如以下是向量化的例子:

1
2
3
4
5
import numpy as np
a = np.zeros((10)) # 新建长度为10的向量,值为0
b = np.ones((10)) # 新建长度为10的向量,值为1
a = a + b # 10个数同时做加法
a = np.exp(a) # 对10个数都做指数运算

numpy 允许一种叫做“广播”的操作,这种操作能够完成不同形状数据间的运算。

1
2
3
a = np.ones(10) # a的形状:[10]
k = np.array([3]) # 用列表[3]新建张量,k的形状:[1]
a = k * a # 广播

这里k的shape为[1],a的shape为[10]。用k乘a,实际上就是令a[i] = k[0] * a[i]。也就是说,k[0]“广播”到了a的每一个元素上。

有一种快速理解广播的方法:可以认为k的形状从[1]变成了[10],再让k和a逐个元素做乘法。

同理,如果用一个a[x, y]的矩阵加一个b[x, 1]的矩阵,实际上是做了下面的运算:

1
2
3
for i in range(x):
for j in range(y):
a[i, j] = a[i, j] + b[i, 0]

用刚刚介绍的方法来理解,可以认为b[x, 1]扩充成了[x, y],再和a做逐个元素的加法运算。

向量化计算前向和反向传播

现在,有了求导的基础知识和向量化计算的基础知识,让我们来写一下如何用矩阵表示逻辑回归中的运算,并用Python代码描述这些计算过程。

单样本的正向传播:

推广到多样本:

这里的$X, A, \hat{Y}$是把原来单样本的列向量$x_i, \hat{y}_i$横向堆叠起来形成的矩阵,即:

单样本反向传播:

$dz$ 是 $\frac{dJ}{dz}$的简写,其他变量同理。编程时也按同样的方式命名。

所有的$\ast$都表示逐元素乘法。比如$[1, 2, 3] \ast [1, 2, 3]=[1, 4, 9]$。$\ast$满足前面提到的广播,比如$[2] \ast [1, 2, 3]=[2, 4, 6]$。

多样本反向传播:

用代码描述多样本前向传播和反向传播就是:

1
2
3
4
5
6
Z = np.dot(w.T, x)+b
A = sigmoid(Z)
dZ = A-Y
dw = np.dot(X, dZ.T) / m
db = np.mean(dZ)
# db=np.sum(dZ) / m

np.dot实现了求向量内积或矩阵乘法,np.sum实现了求和,np.mean实现了求均值。

总结

这堂课的主要知识点有:

  • 什么是二分类问题。
  • 如何对建立逻辑回归模型。
    • Sigmoid 函数 $\sigma(z)=\frac{1}{1 + e^{-z}}$
  • 误差函数与损失函数
    • 逻辑回归的误差函数:$L(\hat{y}, y)=-(y log\hat{y} + (1-y) log(1-\hat{y}))$
  • 用梯度下降算法优化损失函数
  • 计算图的概念及如何利用计算图算梯度

学完这堂课后,应该掌握的编程技能有:

  • 了解numpy基本知识
    • resize
    • .T
    • exp
    • dot
    • mean, sum
  • 用numpy做向量化计算
  • 实现逻辑回归
    • 对输入数据做reshape的预处理
    • 用向量化计算算$\hat{y}$及参数的梯度
    • 迭代优化损失函数

代码实战

这节课有两个编程作业:第一个作业要求使用numpy实现对张量的一些操作,第二个作业要求用逻辑回归实现一个分类器。这些编程作业是在python的notebook上编写的。每道题给出了代码框架,只要写关键的几行代码就行。对我来说,编程体验极差。作为编程最强王者,怎能受此“嗟来之码”的屈辱?我决定从零开始,自己收集数据,并用numpy实现逻辑回归。

其实我不分享作业代码的真正原因是:Coursera不允许公开展示作业代码。在之后的笔记中,我也会分享如何用自己的代码实现每堂课的编程目标。

这篇笔记用到的代码已在GitHub上开源:https://github.com/SingleZombie/DL-Demos/tree/master/LogisticRegression 。下文展示的代码和原本的代码有略微的出入,建议大家对着源代码阅读后文。

程序设计

不管写什么程序,都要先想好整体的架构,再开始动手写代码。

深度学习项目的架构比较固定。一般一个深度学习项目由以下几部分组成:

  • 数据预处理
  • 定义网络结构
  • 定义损失函数
  • 定义优化策略
  • 用训练pipeline串联起网络、损失函数、优化策略
  • 测试模型精度

当然,实现深度学习项目比一般的编程项目多一个步骤:除了写代码外,完成深度学习项目还需要收集数据。

接下来,我将按照数据收集数据处理网络结构损失函数训练测试这几部分介绍这个项目。之后的笔记也会以这个形式介绍编程项目。

数据收集

说起最经典的二分类任务,大家都会想起小猫分类(或许跟吴恩达老师的课比较流行有关)。在这个项目中,我也顺应潮流,选择了一个猫狗数据集(https://www.kaggle.com/datasets/fusicfenta/cat-and-dog?resource=download)。

在此数据集中,数据是按以下结构存储的:

在二分类任务中,数据的标签为0或1(表示是否是小猫)。而此数据集只是把猫、狗的图片分别放到了不同的文件夹里,这意味着我们待会儿要手动给这些数据打上0或1的标签。

数据预处理

由于训练集和测试集的目录结构相同,我们先写一个读数据集的函数:

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
input_shape=(224, 224)
def load_dataset(dir, data_num):
cat_images = glob(osp.join(dir, 'cats', '*.jpg'))
dog_images = glob(osp.join(dir, 'dogs', '*.jpg'))
cat_tensor = []
dog_tensor = []

for idx, image in enumerate(cat_images):
if idx >= data_num:
break
i = cv2.imread(image) / 255
i = cv2.resize(i, input_shape)
cat_tensor.append(i)

for idx, image in enumerate(dog_images):
if idx >= data_num:
break
i = cv2.imread(image) / 255
i = cv2.resize(i, input_shape)
dog_tensor.append(i)

X = cat_tensor + dog_tensor
Y = [1] * len(cat_tensor) + [0] * len(dog_tensor)
X_Y = list(zip(X, Y))
shuffle(X_Y)
X, Y = zip(*X_Y)
return X, Y

函数先是用glob读出文件夹下所有猫狗的图片路径,再按文件路径依次把文件读入。接着,函数为数据生成了0或1的标签。最后,函数把数据打乱,并返回数据。让我们来看看这段代码里有哪些要注意的地方。

在具体介绍代码之前,要说明一下我在这个数据集上做的两个特殊处理:

  1. 这个函数有一个参数data_num,表示我们要读取data_num张猫+data_num张狗的数据。原数据集有上千张图片,直接读进内存肯定会把内存塞爆。为了实现上的方便,我加了一个控制数据数量的参数。在这个项目中,我只用了800张图片做训练集。
  2. 原图片是很大的,为了节约内存,我把所有图片都变成了input_shape=(224, 224)的大小。

接下来,我们再了解一下数据处理中的一些知识。在读数据的时候,把数据归一化(令数据分布在(-1, 1)这个区间内)十分关键。如果不这样做的话,loss里的$loge^{z}$会趋近$log0$,梯度的收敛速度会极慢,训练会难以进行。这是这节课上没有讲的内容,但是它在实战中非常关键。

这个时候输出loss的话,会得到一个Python无法表示的数字:nan。在训练中如果看到loss是nan,多半就是数据没有归一化的原因。这个是一个非常常见的bug,一定要记得做数据归一化!

第三节课里讲了激活函数的收敛速度问题。

现在来详细看代码。

下面的代码用于从文件系统中读取所有图片文件,并把文件的绝对路径保存进一个list。如果大家有疑问,可以自行搜索glob函数的用法。

1
2
cat_images = glob(osp.join(dir, 'cats', '*.jpg'))
dog_images = glob(osp.join(dir, 'dogs', '*.jpg'))

在之后的两段for循环中,我们通过设定循环次数来控制读取的图片数。在循环里,我们先读入文件,再归一化文件,最后把图片resize到(224, 224)。

1
2
3
4
5
6
for idx, image in enumerate(cat_images):
if idx >= data_num:
break
i = cv2.imread(image) / 255
i = cv2.resize(i, input_shape)
cat_tensor.append(i)

在这段代码里,归一化是靠

1
i = cv2.imread(image) / 255

实现的。

这里我们知道输入是图像,颜色通道最大值是255,所以才这样归一化。在很多问题中,我们并不知道数据的边界是多少,这个时候只能用普通的归一化方法了。一种简单的归一化方法是把每个输入向量的模设为1。后面的课程里会详细介绍归一化方法。

读完数据后,我们用以下代码生成了训练输入和对应的标签:

1
2
X = cat_tensor + dog_tensor
Y = [1] * len(cat_tensor) + [0] * len(dog_tensor)

Python里,[1] * 10可以把列表[1]复制10次。

现在,我们的数据是“[猫,猫,猫……狗,狗,狗]”这样整整齐齐地排列着,没有打乱。由于我们是一次性拿整个训练集去训练,训练数据不打乱倒也没事。但为了兼容之后其他训练策略,这里我还是习惯性地把数据打乱了:

1
2
3
X_Y = list(zip(X, Y))
shuffle(X_Y)
X, Y = zip(*X_Y)

使用这三行“魔法Python”可以打乱list对中的数据。

有了读一个文件夹的函数load_dataset,用下面的代码就可以读训练集和测试集:

1
2
3
4
def generate_data(dir='data/archive/dataset', input_shape=(224, 224)):
train_X, train_Y = load_dataset(osp.join(dir, 'training_set'), 400)
test_X, test_Y = load_dataset(osp.join(dir, 'test_set'), 100)
return train_X, train_Y, test_X, test_Y

这里训练集有400+400=800张图片,测试集有100+100=200张图片。如果大家发现内存还是占用太多的话,可以改小这两个数字。

网络结构

在这个项目中,我们使用的是逻辑回归算法。它可以看成是只有一个神经元的神经网络。如之前的课堂笔记所述,我们网络的公式是:

这里我们要实现两个函数:

  1. resize_input:由于图片张量的形状是[h, w, c](高、宽、颜色通道),而网络的输入是一个列向量,我们要把图片张量resize一下。
  2. sigmoid: 我们要用numpy函数组合出一个sigmoid函数。

熟悉了numpy的API后,实现这两个函数还是很容易的:

1
2
3
4
5
6
7
def resize_input(a: np.ndarray):
h, w, c = a.shape
a.resize((h * w * c))
return a

def sigmoid(x):
return 1 / (1 + np.exp(-x))

这里我代码实现上写得有点“脏”,调用resize_input做数据预处理是放在main函数里的:

1
2
3
4
5
6
7
8
9
10
train_X, train_Y, test_X, test_Y = generate_data()

train_X = [resize_input(x) for x in train_X]
test_X = [resize_input(x) for x in test_X]
train_X = np.array(train_X).T
train_Y = np.array(train_Y)
train_Y = train_Y.reshape((1, -1))
test_X = np.array(test_X).T
test_Y = np.array(test_Y)
test_Y = test_Y.reshape((1, -1))

array = array.reshape(a, b) 等价于 array.resize(a, b)。但是,reshape的某一维可以写成-1,表示这一维的大小让程序自己用除法算出来。比如总共有a * b个元素,调用reshape(-1, a)-1的那一维会变成b

经过这些预处理代码,X的shape会变成[$n_x$, $m$],Y的shape会变成[$1$, $m$],和课堂里讲的内容一致。

有了sigmoid函数和正确shape的输入,我们可以写出网络的推理函数:

1
2
def predict(w, b, X):
return sigmoid(np.dot(w.T, X) + b)

损失函数与梯度下降

如前面的笔记所述,损失函数可以用下面的方法计算:

1
2
def loss(y_hat, y):
return np.mean(-(y * np.log(y_hat) + (1 - y) * np.log(1 - y_hat)))

我们定义损失函数,实际上为了求得每个参数的梯度。在求梯度时,其实用不到损失函数本身,只需要知道每个参数对于损失函数的导数。在这个项目中,损失函数只用于输出,以监控当前的训练进度。

而在梯度下降中,我们不需要用到损失函数,只需要算出每个参数的梯度并执行梯度下降:

1
2
3
4
5
6
7
8
def train_step(w, b, X, Y, lr):
m = X.shape[1]
Z = np.dot(w.T, X) + b
A = sigmoid(Z)
d_Z = A - Y
d_w = np.dot(X, d_Z.T) / m
d_b = np.mean(d_Z)
return w - lr * d_w, b - lr * d_b

在这段代码中,我们根据前面算好的公式,算出了w, b的梯度并对w, b进行更新。

训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def init_weights(n_x=224 * 224 * 3):
w = np.zeros((n_x, 1))
b = 0.0
return w, b

def train(train_X, train_Y, step=1000, learning_rate=0.00001):
w, b = init_weights()
print(f'learning rate: {learning_rate}')
for i in range(step):
w, b = train_step(w, b, train_X, train_Y, learning_rate)

# 输出当前训练进度
if i % 10 == 0:
y_hat = predict(w, b, train_X)
ls = loss(y_hat, train_Y)
print(f'step {i} loss: {ls}')
return w, b

有了刚刚的梯度下降函数train_step,训练实现起来就很方便了。我们只需要设置一个训练总次数step,再调用train_step更新参数即可。

测试

在深度学习中,我们要用一个网络从来没有见过的数据集做测试,以验证网络能否泛化到一般的数据上。这里我们直接使用数据集中的test_set,用下面的代码计算分类任务的准确率:

1
2
3
4
5
def test(w, b, test_X, test_Y):
y_hat = predict(w, b, test_X)
predicts = np.where(y_hat > 0.5, 1, 0)
score = np.mean(np.where(predicts == test_Y, 1, 0))
print(f'Accuracy: {score}')

这里的np.where没有在课堂里讲过,这里补充介绍一下。predicts=np.where(y_hat > 0.5, 1, 0)这一行,等价于下面的循环:

1
2
3
4
5
6
7
# 新建一个和y_hat一样形状的ndarray
predicts = np.zeros(y_hat.shape)
for i, v in enumerate(y_hat):
if v > 0.5:
predicts[i] = 1
else:
predicts[i] = 0

也就是说,我们对y_hat做了逐元素的判断v > 0.5?,如果判断成立,则赋值1,否则赋值0。这就好像是一个老师在批改学生的作业,如果对了,就给1分,否则给0分。

y_hat > 0.5是有实际意义的:在二分类问题中,如果网络输出图片是小猫的概率大于0.5,我们就认为图片就是小猫的图片;否则,我们认为不是。

之后,我们用另一个(np.where(predicts == test_Y, 1, 0)来“批改作业”:如果预测值和真值一样,则打1分,否则打0分。

最后,我们用score = np.mean(...)算出每道题分数的平均值,来给整个网络的表现打一个总分。

这里要注意一下,整个项目中我们用了两个方式来评价网络:我们监控了loss,因为loss反映了网络在训练集上的表现;我们计算了网络在测试集上的准确度,因为准确度反映了网络在一般数据上的表现。之后的课堂里应该也会讲到如何使用这些指标来进一步优化网络,这里会算它们就行了。

调参

嘿嘿,想不到吧,除了之前计划的章节外,这里还多了一个趣味性比较强的调参章节。

使用错误代码得到的结果,千万不要学我

搞深度学习,最好玩的地方就是调参数了。通过优化网络的超参数,我们能看到网络的性能在不断变好,准确率在不断变高。这个感觉就和考试分数越来越高,玩游戏刷的伤害越来越高给人带来的成就感一样。

在这个网络中,可以调的参数只有一个学习率。通过玩这个参数,我们能够更直观地认识学习率对梯度下降的影响。

这里我分享一下我的调参结果:

如果学习率>=0.0003,网络更新的步伐过大,从而导致梯度不收敛,训练失败。

text
1
2
3
4
learning rate: 0.0003
step 0 loss: 0.6918513655136874
step 10 loss: 0.9047000002073068
step 20 loss: 0.9751763789675365

学习率==0.0002的话,网络差不多能以最快的速度收敛。

text
1
2
3
4
learning rate: 0.0002
step 0 loss: 0.692168431534233
step 10 loss: 0.684254876013497
step 20 loss: 0.6780829877162996

学习率==0.0001,甚至==0.00003也能训练,但是训练速度会变慢。

text
1
2
3
4
learning rate: 0.0001
step 0 loss: 0.6926003513589579
step 10 loss: 0.6883167092427446
step 20 loss: 0.684621635180076

这里判断网络的收敛速度时,要用到的指标是损失函数。我的代码里默认每10次训练输出一次损失函数的值。

一般大家不会区别误差和损失函数,会把损失函数叫成 loss。

为了节约时间,一开始我只训练了1000步,最后准确率只有0.57左右。哪怕我令输出全部为1,从期望上都能得到0.5的准确率。这个结果确实不尽如人意。

我自己亲手设计的模型,结果怎么能这么差呢?肯定是训练得不够。我一怒之下,加了个零,让程序跑10000步训练。看着loss不断降低,从0.69,到0.4,再到0.3,最后在0.24的小数点第3位之后变动,我的心情也越来越激动:能不能再低点,能不能再创新低?那感觉就像股市开盘看到自己买的股票高开,不断祈祷庄家快点买入一样。

在电脑前,盯着不断更新的控制台快一小时后,loss定格在了0.2385,我总算等到了10000步训练结束的那一刻。模型即将完成测试,准确率即将揭晓。
我定睛一看——准确率居然还只有0.575!

这肯定不是我代码的问题,一定是逻辑回归这个模型太烂了!希望在之后的课程中,我们能够用更复杂的模型跑出更好的结果。

欢迎大家也去下载这个demo(https://github.com/SingleZombie/DL-Demos/tree/master/LogisticRegression),一起调一调参数~

修好bug后的结果

第一次写的代码竟然把梯度全部算错了,这太离谱了,我也不知道当时写代码的时候脑子里进了多少水。修好bug后,我又跑了一次训练。

首先,按照上次的经验,学习率0.0002,跑1000步,就得到了0.59的准确率。这效果差的也太多吧!

接下来训练10000步,我又满怀期待地盯着控制台,看着梯度降到了0.2395。

精度测出来了——好家伙,又是0.575。

行吧,起码文章的内容不用大改了。逻辑回归太菜了,和代码确实没什么关系。

其实,这段写bug经历对我来说是很赚的。我学到了:在梯度算得有问题的情况下,网络可以正常训练,甚至loss还会正常降低。但是,网络的正确率肯定会更低。一定要尊重数学规律,老老实实地按照数学推导的结果写公式。如果没有写bug,我反而学不到这么多东西,反而很亏。

吐槽

把这篇文章刚发到博客上的时候,这篇文章有一堆错误:$W,w$不分,损失函数乱写……。写这种教学文章一定要严谨,尤其是涉及了数学运算的。很多时候程序有bug,根本看不出来。希望我能引以为戒,学踏实了,把文章检查了几遍了,再把文章发出来。

突然又发现一个bug:reshape不是inplace运算。我写得也太潦草了吧!