这堂课要学习的是逻辑回归——一种求解二分类任务的算法。同时,这堂课会补充实现逻辑回归必备的数学知识、编程知识。学完这堂课后,同学们应该能够用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 向量化计算
在刚刚的一轮迭代中,我们要用到两次循环:
- 对$m$个样本循环处理
- 对$n_x$个权重$w_i$与对应的$x_i$相乘
直接拿 Python 写这些 for 循环,程序会跑得很慢的,这里最好使用向量化计算。在这一节里我们补充一下 Python 基础知识,下一节介绍怎么用它们实现逻辑回归的向量化实现。
课程中提到向量化的好处是可以用SIMD(单指令多数据流)优化,这个概念可以理解成计算机会同时对16个或32个数做计算。如果输入的数据是向量的话,相比一个一个做for循环,一次算16,32个数的计算速度会更快。
但实际上,除了无法使用SIMD以外,Python的低效也是拖慢速度的原因之一。哪怕是不用SIMD,单纯地用C++的for循环实现向量化计算,都能比用Python的循环快上很多。
Python 的 numpy 库提供了向量化计算的接口。比如以下是向量化的例子:
1 | import numpy as np |
numpy 允许一种叫做“广播”的操作,这种操作能够完成不同形状数据间的运算。
1 | a = np.ones(10) # a的形状:[10] |
这里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
3for 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 | Z = np.dot(w.T, x)+b |
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
27input_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的标签。最后,函数把数据打乱,并返回数据。让我们来看看这段代码里有哪些要注意的地方。
在具体介绍代码之前,要说明一下我在这个数据集上做的两个特殊处理:
- 这个函数有一个参数
data_num
,表示我们要读取data_num
张猫+data_num
张狗的数据。原数据集有上千张图片,直接读进内存肯定会把内存塞爆。为了实现上的方便,我加了一个控制数据数量的参数。在这个项目中,我只用了800张图片做训练集。 - 原图片是很大的,为了节约内存,我把所有图片都变成了input_shape=(224, 224)的大小。
接下来,我们再了解一下数据处理中的一些知识。在读数据的时候,把数据归一化(令数据分布在(-1, 1)这个区间内)十分关键。如果不这样做的话,loss里的$loge^{z}$会趋近$log0$,梯度的收敛速度会极慢,训练会难以进行。这是这节课上没有讲的内容,但是它在实战中非常关键。
这个时候输出loss的话,会得到一个Python无法表示的数字:
nan
。在训练中如果看到loss是nan
,多半就是数据没有归一化的原因。这个是一个非常常见的bug,一定要记得做数据归一化!第三节课里讲了激活函数的收敛速度问题。
现在来详细看代码。
下面的代码用于从文件系统中读取所有图片文件,并把文件的绝对路径保存进一个list。如果大家有疑问,可以自行搜索glob
函数的用法。1
2cat_images = glob(osp.join(dir, 'cats', '*.jpg'))
dog_images = glob(osp.join(dir, 'dogs', '*.jpg'))
在之后的两段for循环中,我们通过设定循环次数来控制读取的图片数。在循环里,我们先读入文件,再归一化文件,最后把图片resize到(224, 224)。1
2
3
4
5
6for 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
2X = cat_tensor + dog_tensor
Y = [1] * len(cat_tensor) + [0] * len(dog_tensor)
Python里,
[1] * 10
可以把列表[1]
复制10次。
现在,我们的数据是“[猫,猫,猫……狗,狗,狗]”这样整整齐齐地排列着,没有打乱。由于我们是一次性拿整个训练集去训练,训练数据不打乱倒也没事。但为了兼容之后其他训练策略,这里我还是习惯性地把数据打乱了:1
2
3X_Y = list(zip(X, Y))
shuffle(X_Y)
X, Y = zip(*X_Y)
使用这三行“魔法Python”可以打乱list
对中的数据。
有了读一个文件夹的函数load_dataset
,用下面的代码就可以读训练集和测试集:1
2
3
4def 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张图片。如果大家发现内存还是占用太多的话,可以改小这两个数字。
网络结构
在这个项目中,我们使用的是逻辑回归算法。它可以看成是只有一个神经元的神经网络。如之前的课堂笔记所述,我们网络的公式是:
这里我们要实现两个函数:
- resize_input:由于图片张量的形状是[h, w, c](高、宽、颜色通道),而网络的输入是一个列向量,我们要把图片张量resize一下。
- sigmoid: 我们要用
numpy
函数组合出一个sigmoid
函数。
熟悉了numpy
的API后,实现这两个函数还是很容易的:1
2
3
4
5
6
7def 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 | train_X, train_Y, test_X, test_Y = generate_data() |
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 | def predict(w, b, X): |
损失函数与梯度下降
如前面的笔记所述,损失函数可以用下面的方法计算:
1 | def loss(y_hat, y): |
我们定义损失函数,实际上为了求得每个参数的梯度。在求梯度时,其实用不到损失函数本身,只需要知道每个参数对于损失函数的导数。在这个项目中,损失函数只用于输出,以监控当前的训练进度。
而在梯度下降中,我们不需要用到损失函数,只需要算出每个参数的梯度并执行梯度下降:
1 | def train_step(w, b, X, Y, lr): |
在这段代码中,我们根据前面算好的公式,算出了w, b
的梯度并对w, b
进行更新。
训练
1 | def init_weights(n_x=224 * 224 * 3): |
有了刚刚的梯度下降函数train_step
,训练实现起来就很方便了。我们只需要设置一个训练总次数step
,再调用train_step
更新参数即可。
测试
在深度学习中,我们要用一个网络从来没有见过的数据集做测试,以验证网络能否泛化到一般的数据上。这里我们直接使用数据集中的test_set
,用下面的代码计算分类任务的准确率:
1 | def test(w, b, test_X, test_Y): |
这里的np.where
没有在课堂里讲过,这里补充介绍一下。predicts=np.where(y_hat > 0.5, 1, 0)
这一行,等价于下面的循环:
1 | # 新建一个和y_hat一样形状的ndarray |
也就是说,我们对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,网络更新的步伐过大,从而导致梯度不收敛,训练失败。
1 | learning rate: 0.0003 |
学习率==0.0002的话,网络差不多能以最快的速度收敛。
1 | learning rate: 0.0002 |
学习率==0.0001,甚至==0.00003也能训练,但是训练速度会变慢。
1 | learning rate: 0.0001 |
这里判断网络的收敛速度时,要用到的指标是损失函数。我的代码里默认每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运算。我写得也太潦草了吧!