0%

吴恩达《深度学习专项》笔记+代码实战(三):“浅度”神经网络

在上节课中,我们学习了逻辑回归——一种经典的学习算法。我兴致勃勃地用它训练了一个猫狗分类模型,结果只得到了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,在可视化网络的输出结果。可以说我的编程水平相比普通人已经登峰造极了。但我还会继续精进我的编程技术,直至出神入化,神鬼莫及的境界。

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