【作者主页】Francek Chen
【专栏介绍】 ⌈ ⌈ ⌈Python机器学习 ⌋ ⌋ ⌋ 机器学习是一门人工智能的分支学科,通过算法和模型让计算机从数据中学习,进行模型训练和优化,做出预测、分类和决策支持。Python成为机器学习的首选语言,依赖于强大的开源库如Scikit-learn、TensorFlow和PyTorch。本专栏介绍机器学习的相关算法以及基于Python的算法实现。
【GitCode】专栏资源保存在我的GitCode仓库:https://gitcode.com/Morse_Chen/Python_machine_learning。
本文将会介绍机器学习中最重要的内容之一——神经网络(neural network,NN),它是深度学习的基础。神经网络的名称来源于生物中的神经元。自有计算机以来,人们就希望能让计算机具有和人类一样的智能,因此,许多研究者将目光放到了人类的大脑结构上。作为生物神经系统的基本单元,神经元在形成智能的过程中起到了关键作用。神经元的结构并不复杂,简单来说,神经元由树突、轴突和细胞体构成。图1是神经元的结构示意图。由其他神经元传来的神经脉冲在细胞间通过神经递质传输。神经递质被树突接收后,相应的神经信号传给细胞体,由细胞体进行处理并积累。当积累神经递质的兴奋性超过了某个阈值,就会触发一个动作电位,将新的信号传至轴突末梢的突触,释放神经递质给下一个神经元。生物的智能、运动等几乎所有生命活动的控制信号都由这些看似简单的神经元进行传输。
一、人工神经网络
既然动物能够通过神经元建立智能,我们自然会想,能不能通过模拟神经元的结构和行为方式,建立起人工智能呢?于是,从1943年的沃伦·麦卡洛克(Warren McCulloch)和沃尔特·皮茨(Walter Pitts)开始,研究者们设计了人工神经网络(artificial neural network,ANN),现在通常简称为神经网络(NN)。在NN中,最基本的单位也是神经元。在最开始的设计中,人工神经元完全仿照生物神经元的结构和行为方式,而大量的神经元互相连接,构成一张有向图。每个神经元是一个节点,神经元之间的连接就作为有向边。设神经元 i = 1 , … , N j i=1,\ldots,N_j i=1,…,Nj向神经元 j j j发送了信号 o i o_i oi,那么在神经元 j j j中,这些神经元发送的信号通过连接权重 w j i w_{ji} wji加权求和,得到内部信号总和 z j z_j zj,即 z j = ∑ i = 1 N j w j i o i z_j = \sum_{i=1}^{N_j} w_{ji} o_i zj=i=1∑Njwjioi
此外,每个神经元 j j j还有提前设置好的阈值 T j T_j Tj,当处理结果 z j < T j z_j < T_j zj<Tj 时,神经元 j j j的输出 o j = 0 o_j=0 oj=0,反之输出 o j = 1 o_j=1 oj=1。这一设计是为了模拟生物神经元的激励与抑制信号。这样,当某个神经元收到外部输入时,就会对输入进行处理,再按上面的计算方式给其指向的其他神经元输出新的信号。这样的行为方式与生物神经元非常相似,然而,其表达能力十分有限,能解决的问题也很少。此外,每个神经元上的参数还需要人为指定。因此,这时的神经网络还只是具有雏形。
二、感知机
上文提到的神经网络的最大问题在于,每条边上的权重都需要人为指定。当神经网络的规模较大、结构较为复杂时,我们很难先验地通过数学方法计算出合适的权重,从而这样的网络也很难用来解决实际问题。为了简化复杂的神经网络,1958年,弗兰克·罗森布拉特(Frank Rosenblatt)提出了感知机(perceptron)的概念。他从生物接受刺激、产生感知的过程出发,用神经网络抽象出了这一模型,如图2所示。与原始的神经网络类似,输入经过神经元后被乘上权重并求和。但是,感知机还额外引入了偏置(bias) b b b项,把它一起加到求和的结果上。最后,该结果再通过模型的激活函数(activation function),得到最终的输出。
感知机中有两个新引入的结构。第一是偏置,它相当于给线性变换加入了常数项。我们知道,对于一维的线性函数 f ( x ) = k x f(x)=kx f(x)=kx 来说,无论我们如何调整参数 k k k,它都一定经过原点。而加入常数项变为 f ( x ) = k x + b f(x)=kx+b f(x)=kx+b 后,它就可以表示平面上的任意一条直线,模型的表达能力大大提高了。第二是激活函数,它可以模拟神经元的兴奋和抑制状态。在感知机中,激活函数通常是示性函数 I ( z ≥ 0 ) \mathbb{I}(z\ge0) I(z≥0)。当输入 z z z非负时,函数输出1,对应兴奋状态;输入为负数时,函数输出0,对应抑制状态。整个感知机模拟了一组神经元受到输入刺激后给出反应的过程,可以用来解决二分类问题。
感知机最重要的进步在于,它的参数可以自动调整,无须再由人工繁琐地一个一个调试。假设二分类问题中,样本的特征为 x 1 , … , x m x_1,\ldots,x_m x1,…,xm,标签 y ∈ { 0 , 1 } y\in\{0,1\} y∈{0,1}。那么感知机对该样本的预测输出为 y ^ = I ( ∑ i = 1 m w i x i + b ≥ 0 ) \hat y = \mathbb{I}\left(\sum_{i=1}^m w_ix_i + b \ge 0\right) y^=I(i=1∑mwixi+b≥0)
罗森布拉特利用生物中的负反馈调节机制来调整感知机的参数。对于该样本,感知机收到的反馈为
y
^
−
y
\hat y - y
y^−y,其参数根据反馈进行更新:
w
i
←
w
i
−
η
(
y
^
−
y
)
x
i
b
i
←
b
i
−
η
(
y
^
−
y
)
\begin{aligned} w_i & \gets w_i - \eta (\hat y - y) x_i \\ b_i & \gets b_i - \eta (\hat y - y) \end{aligned}
wibi←wi−η(y^−y)xi←bi−η(y^−y) 其中,
η
\eta
η是学习率。如果感知机的预测正确,即
y
^
=
y
\hat y = y
y^=y,其收到的反馈为0,参数不更新。如果感知机预测为0,但样本的真实标签为1,感知机收到的反馈为-1,说明其预测结果整体偏大,需要将权重和偏置下调;如果感知机预测为1,真实标签为0,则需要将权重和偏置上调。可以看出,这一思想已经具有了梯度下降法的影子。凭借着参数自动训练的优点,感知机成为了第一个可以解决简单实际问题的神经网络。罗森布拉特曾在电脑中构建出感知机模型,并用打孔卡片进行训练,卡片上的孔位于左侧或右侧。在50次试验后,模型学会了判断卡片上的孔位于哪一边。
然而,感知机模型存在致命的缺陷,那就是它只能处理线性问题。1969年,马文·明斯基(Marvin Minsky)提出了异或问题。对输入 x 1 , x 2 ∈ { 0 , 1 } x_1,x_2 \in \{0,1\} x1,x2∈{0,1},当其相同时输出0,不同时输出1。作为一个简单的逻辑运算,异或的真值表如表1所示。
x 1 x_1 x1 | x 2 x_2 x2 | x 1 x o r x 2 x_1\ \mathrm{xor}\ x_2 x1 xor x2 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
异或问题的输入只有4种可能,如果把所有输入和其输出在平面直角坐标系中画出来,无非是4个点而已,如图3所示。其中,左下和右上的点是红色,左上和右下的点是蓝色。要解决异或问题,模型只需要将红色和蓝色的点区分开。然而,读者可以自行验证,红点和蓝点无法只通过一条直线分隔开,从而感知机无法解决异或问题。这一事实被提出后,感知机的表达能力和应用场景遭到广泛质疑,神经网络的研究也陷入了寒冬。
三、隐含层与多层感知机
为了进一步增强网络的表达能力,突破只能解决线性问题的困境,有研究者提出增加网络的层数,即将一个感知机的输出作为输入,连接到下一个感知机上。如果一个感知机对应平面上的一条直线,那么多个感知机就可以将平面分隔成多边形区域,达到超越线性的效果。图4给出了一个形象的示例。
然而,如果不同层之间的神经元可以随意连接,往往会有多种结构可以解决同一个问题,从而大大增加结构设计的难度。例如,图5中的两种结构都可以解决异或问题,其中边上的数字代表权重 w w w。偏置和激活函数都直接标注在了神经元上,神经元将所有输入相加后经过激活函数,再向后输出。大家可以自行验证这两种结构的正确性。
因此,当我们组合多个单层的感知机时,通常采用前馈(feedforward)结构,即将神经元分为不同的层,每一层只和其前后相邻层的神经元连接,层内以及间隔一层以上的神经元之间没有连接。这样,我们可以将网络的结构分为直接接收输入的输入层(input layer)、中间进行处理的隐含层(hidden layer)、以及最终给出结果的输出层(output layer)。图6是一个两层的前馈网络示意图。需要注意,前馈网络的层数是指权重的层数,即边的层数,而神经元上不携带权重。
将多个单层感知机按前馈结构组合起来,就形成了多层感知机(multi-layer perceptron,MLP)。事实上,图6已经是一个两层的多层感知机,隐含层神经元内部的 ϕ \phi ϕ和输出层神经元内部的 ϕ o u t \phi_\mathrm{out} ϕout分别代表隐含层和输出层的激活函数。偏置项可以与权重合并,因此图中将其略去。
下面,我们以图7中的三层MLP为例,详细写出MLP中由输入计算得到输出的过程。设第 i i i层的权重、偏置和激活函数分别为 W i \boldsymbol W_i Wi, b i \boldsymbol b_i bi和 ϕ i \phi_i ϕi。那么对于输入 x ∈ R 3 \boldsymbol x \in \mathbb{R}^3 x∈R3,该MLP进行的运算依次为:
-
第一层: l 1 = ϕ 1 ( W 1 x + b 1 ) \boldsymbol l_1 = \phi_1(\boldsymbol W_1 \boldsymbol x + \boldsymbol b_1) l1=ϕ1(W1x+b1)。由于下一层的维度是4,因此 W 1 ∈ R 4 × 3 \boldsymbol W_1 \in \mathbb{R}^{4\times3} W1∈R4×3, b 1 ∈ R 4 \boldsymbol b_1 \in \mathbb{R}^4 b1∈R4,输出 l 1 ∈ R 4 \boldsymbol l_1 \in \mathbb{R}^4 l1∈R4。
-
第二层: ϕ 2 = f 2 ( W 2 l 1 + b 2 ) \boldsymbol \phi_2 = f_2(\boldsymbol W_2 \boldsymbol l_1 + \boldsymbol b_2) ϕ2=f2(W2l1+b2)。同理, W 2 ∈ R 2 × 4 \boldsymbol W_2 \in \mathbb{R}^{2\times 4} W2∈R2×4, b 2 = R 2 \boldsymbol b_2 = \mathbb{R}^2 b2=R2,输出 l 2 ∈ R 2 \boldsymbol l_2 \in \mathbb{R}^2 l2∈R2。
-
第三层,即输出层: y = ϕ 3 ( W 3 l 2 + b 3 ) y = \phi_3(\boldsymbol W_3 \boldsymbol l_2 + b_3) y=ϕ3(W3l2+b3)。由于输出 y y y是标量,这里 W 3 ∈ R 1 × 2 \boldsymbol W_3 \in \mathbb{R}^{1\times 2} W3∈R1×2, b 3 ∈ R b_3 \in \mathbb{R} b3∈R。
如果把上面三层所做的运算连起来写,就得到 y = ϕ 3 ( W 3 ϕ 2 ( W 2 ϕ 1 ( W 1 x + b 1 ) + b 2 ) + b 3 ) y = \phi_3(\boldsymbol W_3 \phi_2(\boldsymbol W_2 \phi_1(\boldsymbol W_1 x + \boldsymbol b_1) + \boldsymbol b_2) + b_3) y=ϕ3(W3ϕ2(W2ϕ1(W1x+b1)+b2)+b3) 那么这些激活函数能否就用线性函数来实现,还是说一定得是非线性函数呢?我们可以做以下简单推导:假如所有的激活函数 ϕ \phi ϕ都是线性函数,即 ϕ ( W x + b ) = W ϕ ( x ) + b \phi(\boldsymbol W \boldsymbol x + \boldsymbol b) = \boldsymbol W \phi(\boldsymbol x) + \boldsymbol b ϕ(Wx+b)=Wϕ(x)+b,那么上式就会变为 y = W 3 W 2 W 1 ϕ 3 ( ϕ 2 ( ϕ 1 ( x ) ) ) + W 3 W 2 b 1 + W 3 b 2 + b 3 y = \boldsymbol W_3 \boldsymbol W_2 \boldsymbol W_1 \phi_3(\phi_2(\phi_1(\boldsymbol x))) + \boldsymbol W_3 \boldsymbol W_2 \boldsymbol b_1 + \boldsymbol W_3 \boldsymbol b_2 + b_3 y=W3W2W1ϕ3(ϕ2(ϕ1(x)))+W3W2b1+W3b2+b3 这与一个权重 W = W 3 W 2 W 1 \boldsymbol W = \boldsymbol W_3 \boldsymbol W_2 \boldsymbol W_1 W=W3W2W1,偏置 b = W 3 W 2 b 1 + W 3 b 2 + b 3 \boldsymbol b= \boldsymbol W_3 \boldsymbol W_2 \boldsymbol b_1 + \boldsymbol W_3 \boldsymbol b_2 + b_3 b=W3W2b1+W3b2+b3,激活函数为 ϕ 3 ( ϕ 2 ( ϕ 1 ( x ) ) ) \phi_3(\phi_2(\phi_1(x))) ϕ3(ϕ2(ϕ1(x)))的单层感知机完全一致。这样的多层感知机仅在线性意义上提高了网络的表达能力,仍然是在用多个超平面来对样本进行分割。因此,激活函数一定得是非线性的,才能使网络模型有更广的拟合能力。目前,常用的激活函数有:
-
逻辑斯谛函数: σ ( x ) = 1 / ( 1 + e − x ) \sigma(x) = 1 / (1+\text{e}^{-x}) σ(x)=1/(1+e−x)。该函数又称sigmoid函数,在逻辑斯谛回归一文中已经介绍过,会将 x x x映射到 ( 0 , 1 ) (0,1) (0,1)区间内。直观上,可以将0对应生物神经元的静息状态,1对应兴奋状态。相比于示性函数 I ( x ≥ 0 ) \mathbb{I}(x\ge0) I(x≥0),逻辑斯谛函数更加平滑,并且易于求导。逻辑斯谛函数的推广形式是softmax函数,两者没有本质不同。
-
双曲正切(tanh)函数: tanh ( x ) = ( e x − e − x ) / ( e x + e − x ) \tanh(x) = (\text e^x - \text e^{-x}) / (\text e^x + \text e^{-x}) tanh(x)=(ex−e−x)/(ex+e−x)。该函数将 x x x映射到 ( − 1 , 1 ) (-1, 1) (−1,1),图像如图8所示,与逻辑斯谛函数均为“S”形曲线,同样常用与分类任务。
- 线性整流单元(rectified linear unit,ReLU): R e L U ( x ) = max { x , 0 } \mathrm{ReLU}(x) = \max\{x, 0\} ReLU(x)=max{x,0}。该函数将小于0的输入都变成0,而大于0的输入保持原样,图像如图9所示。虽然函数的两部分都是线性的,但在大于0的部分并不像示性函数 I ( x ≥ 0 ) \mathbb{I}(x \ge 0) I(x≥0)一样是常数,因此存在梯度,并且保持了原始的输入信息。一些研究表明,ReLU函数将大约一半的神经元输出设置为0、即静息状态的做法,与生物神经元有相似之处。
在实践中,考虑到不同隐含层之间的对称性,我们一般让所有隐含层的激活函数相同。而ReLU函数作为计算最简单、又易于求导的选择,在绝大多数情况下都被用作隐含层的激活函数。输出层的激活函数与任务对输出的要求直接相关,需要根据不同的任务而具体选择。例如,二分类问题可以选用逻辑斯谛函数,多分类问题可以选用softmax函数,要求输出在 ( a , b ) (a,b) (a,b)区间内的问题可以选用 b − a 2 tanh ( x ) + b + a 2 \begin{aligned}\frac{b-a}{2}\tanh(x) + \frac{b+a}{2}\end{aligned} 2b−atanh(x)+2b+a。MLP相比于单层感知机的表达能力提升,关键就在于非线性激活函数的复合。理论上可以证明,任意一个 R n \mathbb{R}^n Rn上的连续函数,都可以由大小合适的MLP来拟合,而对其非线性激活函数的形式要求很少。该定理称为普适逼近定理,为神经网络的有效性给出了最基础的理论保证。
从上面的分析中可以看出,非线性部分对提升模型的表达能力十分重要。事实上,非线性变换相当于提升了数据的维度。例如二维平面上的点 ( x 1 , x 2 ) (x_1,x_2) (x1,x2),经过变换 f ( x 1 , x 2 ) = x 1 2 + x 2 2 f(x_1,x_2)=x_1^2+x_2^2 f(x1,x2)=x12+x22,就可以看作三维空间中的点 ( x 1 , x 2 , x 1 2 + x 2 2 ) (x_1,x_2,x_1^2+x_2^2) (x1,x2,x12+x22)。原本在同一平面上的点经过这样的非线性变换,就分布到三维空间中去了。但如果变换是线性的,原本在同一平面上的点变换后在空间中仍然位于同一平面上,只不过是所处的平面做了平移、旋转。虽然看上去这些点也在三维空间,但本质上来说,数据间的相对位置关系并没有改变。因此,线性变换对提升模型的表达能力没有太大帮助,而连续的线性变换如上面的推导所示,还可以合并成一个线性变换。
数据维度提升的好处在于,在低维空间中线性不可分的数据,经过合适的非线性变换,在高维空间中可能变得线性可分。例如,在前文描述的异或问题中,我们通过某种非线性变换,将原本在平面上的4个点映射到三维空间去。如图10所示,右边的绿色平面是 z = 0 z=0 z=0 平面,箭头表示将原本二维的点变换到三维。在变换过后,就可以用 I ( z ≥ 0 ) \mathbb{I}(z\ge0) I(z≥0)来直接对样本进行分类。因此,MLP中要不断通过非线性的激活函数提升数据的维度,从而提升表达能力。
思考:将值域为 ( − 1 , 1 ) (-1,1) (−1,1) 的 tanh ( x ) \tanh(x) tanh(x)变形可以使值域拓展到 ( a , b ) (a,b) (a,b)。对于更一般的情况,推导将 [ m , n ] [m,n] [m,n] 区间均匀映射到 [ a , b ] [a,b] [a,b] 区间的变换 f f f。均匀映射可以理解为,对于任意 [ u , v ] ⊂ [ m , n ] [u,v] \subset [m,n] [u,v]⊂[m,n],都有 v − u n − m = f ( v ) − f ( u ) b − a \begin{aligned}\frac{v-u}{n-m} = \frac{f(v) - f(u)}{b-a}\end{aligned} n−mv−u=b−af(v)−f(u)。
要将区间 [ m , n ] [m, n] [m,n]均匀映射到区间 [ a , b ] [a, b] [a,b],我们可以使用线性变换函数 f ( x ) f(x) f(x)。
1. 线性变换公式
线性变换函数可以表示为:
f
(
x
)
=
b
−
a
n
−
m
(
x
−
m
)
+
a
f(x) = \frac{b - a}{n - m} (x - m) + a
f(x)=n−mb−a(x−m)+a
2. 验证均匀映射条件
为了验证映射
f
(
x
)
f(x)
f(x)确实满足均匀映射条件:
v
−
u
n
−
m
=
f
(
v
)
−
f
(
u
)
b
−
a
\frac{v - u}{n - m} = \frac{f(v) - f(u)}{b - a}
n−mv−u=b−af(v)−f(u) 我们计算
f
(
u
)
f(u)
f(u)和
f
(
v
)
f(v)
f(v):
f
(
u
)
=
b
−
a
n
−
m
(
u
−
m
)
+
a
f
(
v
)
=
b
−
a
n
−
m
(
v
−
m
)
+
a
f(u) = \frac{b - a}{n - m} (u - m) + a \\[2ex] f(v) = \frac{b - a}{n - m} (v - m) + a
f(u)=n−mb−a(u−m)+af(v)=n−mb−a(v−m)+a 然后:
f
(
v
)
−
f
(
u
)
=
[
b
−
a
n
−
m
(
v
−
m
)
+
a
]
−
[
b
−
a
n
−
m
(
u
−
m
)
+
a
]
f
(
v
)
−
f
(
u
)
=
b
−
a
n
−
m
(
v
−
m
)
−
b
−
a
n
−
m
(
u
−
m
)
f
(
v
)
−
f
(
u
)
=
b
−
a
n
−
m
(
v
−
u
)
f(v) - f(u) = \left[ \frac{b - a}{n - m} (v - m) + a \right] - \left[ \frac{b - a}{n - m} (u - m) + a \right] \\[2ex] f(v) - f(u) = \frac{b - a}{n - m} (v - m) - \frac{b - a}{n - m} (u - m) \\[2ex] f(v) - f(u) = \frac{b - a}{n - m} (v - u)
f(v)−f(u)=[n−mb−a(v−m)+a]−[n−mb−a(u−m)+a]f(v)−f(u)=n−mb−a(v−m)−n−mb−a(u−m)f(v)−f(u)=n−mb−a(v−u) 计算:
f
(
v
)
−
f
(
u
)
b
−
a
=
b
−
a
n
−
m
(
v
−
u
)
b
−
a
f
(
v
)
−
f
(
u
)
b
−
a
=
v
−
u
n
−
m
\frac{f(v) - f(u)}{b - a} = \frac{\frac{b - a}{n - m} (v - u)}{b - a} \\[2ex] \frac{f(v) - f(u)}{b - a} = \frac{v - u}{n - m}
b−af(v)−f(u)=b−an−mb−a(v−u)b−af(v)−f(u)=n−mv−u 这正是我们需要的均匀映射条件。
因此,线性变换 f ( x ) = b − a n − m ( x − m ) + a \begin{aligned}f(x) = \frac{b - a}{n - m} (x - m) + a\end{aligned} f(x)=n−mb−a(x−m)+a 将区间 [ m , n ] [m, n] [m,n] 均匀映射到区间 [ a , b ] [a, b] [a,b]。
四、反向传播
为了调整多层感知机的参数,训练神经网络,设最小化的目标函数为
J
(
x
)
J(x)
J(x),我们依然需要计算目标函数对网络中各个参数的梯度
∇
J
\nabla J
∇J。对于前馈网络来说,其每一层的计算是依次进行的。以上文的三层MLP为例,按照梯度计算的链式法则,可以得到
∂
J
∂
W
i
=
∂
J
∂
y
∂
y
∂
W
i
,
∂
J
∂
b
i
=
∂
J
∂
y
∂
y
∂
b
i
\frac{\partial J}{\partial \boldsymbol W_i} = \frac{\partial J}{\partial y} \frac{\partial y}{\partial \boldsymbol W_i}, \quad \frac{\partial J}{\partial \boldsymbol b_i} = \frac{\partial J}{\partial y} \frac{\partial y}{\partial \boldsymbol b_i}
∂Wi∂J=∂y∂J∂Wi∂y,∂bi∂J=∂y∂J∂bi∂y 比照图11,
y
y
y对参数
W
i
\boldsymbol W_i
Wi和
b
i
\boldsymbol b_i
bi的梯度可以从后向前依次计算得
{
∂
y
∂
W
3
=
ϕ
3
′
(
W
3
l
2
+
b
3
)
l
2
∂
y
∂
b
3
=
ϕ
3
′
(
W
3
l
2
+
b
3
)
∂
y
∂
l
2
=
W
3
ϕ
3
′
(
W
3
l
2
+
b
3
)
\left\{ \begin{aligned} \frac{\partial y}{\partial \boldsymbol W_3} &= \phi_3'(\boldsymbol W_3 \boldsymbol l_2 + \boldsymbol b_3) \boldsymbol l_2 \\[2ex] \frac{\partial y}{\partial b_3} &= \phi_3'(\boldsymbol W_3 \boldsymbol l_2 + \boldsymbol b_3) \\[2ex] {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}} &= \boldsymbol W_3 \phi_3'(\boldsymbol W_3 \boldsymbol l_2 + \boldsymbol b_3) \end{aligned}\right.
⎩
⎨
⎧∂W3∂y∂b3∂y∂l2∂y=ϕ3′(W3l2+b3)l2=ϕ3′(W3l2+b3)=W3ϕ3′(W3l2+b3)
{
∂
y
∂
W
2
=
∂
y
∂
l
2
∂
l
2
∂
W
2
=
∂
y
∂
l
2
ϕ
2
′
(
W
2
l
1
+
b
2
)
l
1
∂
y
∂
b
2
=
∂
y
∂
l
2
∂
l
2
∂
b
2
=
∂
y
∂
l
2
ϕ
2
′
(
W
2
l
1
+
b
2
)
∂
y
∂
l
1
=
∂
y
∂
l
2
∂
l
2
∂
l
1
=
∂
y
∂
l
2
W
2
ϕ
2
′
(
W
2
l
1
+
b
2
)
\left\{\begin{aligned} \frac{\partial y}{\partial \boldsymbol W_2} &= {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}}\frac{\partial \boldsymbol l_2}{\partial \boldsymbol W_2} = {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}} \phi_2'(\boldsymbol W_2 \boldsymbol l_1 + \boldsymbol b_2) \boldsymbol l_1 \\[2ex] \frac{\partial y}{\partial \boldsymbol b_2} &= {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}} \frac{\partial \boldsymbol l_2}{\partial \boldsymbol b_2} = {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}} \phi_2'(\boldsymbol W_2 \boldsymbol l_1 + \boldsymbol b_2) \\[2ex] {\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} &= {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}} \frac{\partial \boldsymbol l_2}{\partial \boldsymbol l_1} = {\color{red}{\frac{\partial y}{\partial \boldsymbol l_2}}} \boldsymbol W_2 \phi_2'(\boldsymbol W_2 \boldsymbol l_1 + \boldsymbol b_2) \end{aligned}\right.
⎩
⎨
⎧∂W2∂y∂b2∂y∂l1∂y=∂l2∂y∂W2∂l2=∂l2∂yϕ2′(W2l1+b2)l1=∂l2∂y∂b2∂l2=∂l2∂yϕ2′(W2l1+b2)=∂l2∂y∂l1∂l2=∂l2∂yW2ϕ2′(W2l1+b2)
{
∂
y
∂
W
1
=
∂
y
∂
l
1
∂
l
1
∂
W
1
=
∂
y
∂
l
1
ϕ
1
′
(
W
1
x
+
b
1
)
x
∂
y
∂
b
1
=
∂
y
∂
l
1
∂
l
1
∂
b
1
=
∂
y
∂
l
1
ϕ
1
′
(
W
1
x
+
b
1
)
∂
y
∂
x
=
∂
y
∂
l
1
∂
l
1
∂
x
=
∂
y
∂
l
1
W
1
ϕ
1
′
(
W
1
x
+
b
1
)
\left\{\begin{aligned} \frac{\partial y}{\partial \boldsymbol W_1} &= {\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} \frac{\partial \boldsymbol l_1}{\partial \boldsymbol W_1} ={\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} \phi_1'(\boldsymbol W_1 \boldsymbol x + \boldsymbol b_1) \boldsymbol x \\[2ex] \frac{\partial y}{\partial \boldsymbol b_1} &= {\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} \frac{\partial \boldsymbol l_1}{\partial \boldsymbol b_1} = {\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} \phi_1'(\boldsymbol W_1 \boldsymbol x + \boldsymbol b_1) \\[2ex] \frac{\partial y}{\partial \boldsymbol x} &={\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} \frac{\partial \boldsymbol l_1}{\partial \boldsymbol x} = {\color{blue}{\frac{\partial y}{\partial \boldsymbol l_1}}} \boldsymbol W_1 \phi_1'(\boldsymbol W_1 \boldsymbol x + \boldsymbol b_1) \end{aligned}\right.
⎩
⎨
⎧∂W1∂y∂b1∂y∂x∂y=∂l1∂y∂W1∂l1=∂l1∂yϕ1′(W1x+b1)x=∂l1∂y∂b1∂l1=∂l1∂yϕ1′(W1x+b1)=∂l1∂y∂x∂l1=∂l1∂yW1ϕ1′(W1x+b1)
在计算过程中,我们用同样的颜色标出了重复的部分。需要注意,某些时候输入 x \boldsymbol x x也会变成可以优化的参数,因此上式最后我们也写出了对输入 x \boldsymbol x x求导的结果。按照这样的顺序,我们在最后一层计算出的 ∂ y ∂ l 2 \begin{aligned}\frac{\partial y}{\partial \boldsymbol l_2}\end{aligned} ∂l2∂y,可以直接用在倒数第二层的梯度计算上,以此类推。因此,每一层的梯度由两部分组成。一部分是当前的激活函数计算出的梯度,另一部分是后一层回传的梯度。像这样梯度由后向前传播的算法,就称为线性整流单元反向传播(back propagation,BP)算法,该算法是现代神经网络训练的核心之一。从图11中也可以看出,网络计算输出时的绿色数据流向和损失函数求导时的红色梯度流向恰好相反。从数学的角度来讲,反向传播算法成立的根本原因是链式求导法则,从而靠后的层计算得到的结果可以在靠前的层中反复使用,无须在每一层都从头计算,大大提高了梯度计算的效率。
最后,设学习率为 η \eta η,我们可以根据梯度下降算法更新网络的参数: W i ← W i − η ∂ J ∂ W i , b i ← b i − η ∂ J ∂ b i , i = 1 , 2 , 3 \boldsymbol W_i \gets \boldsymbol W_i - \eta \frac{\partial J}{\partial \boldsymbol W_i}, \quad \boldsymbol b_i \gets \boldsymbol b_i - \eta \frac{\partial J}{\partial \boldsymbol b_i},\quad i = 1,2,3 Wi←Wi−η∂Wi∂J,bi←bi−η∂bi∂J,i=1,2,3
思考:试计算逻辑斯谛函数、 tanh \tanh tanh梯度的取值区间,并根据反向传播的公式思考:当MLP的层数比较大时,其梯度计算会有什么影响?
1. 逻辑斯谛函数(Sigmoid)和双曲正切函数(Tanh)的梯度取值区间
(1)逻辑斯谛函数(Sigmoid)
逻辑斯谛函数定义为:
σ
(
x
)
=
1
1
+
e
−
x
\sigma(x) = \frac{1}{1 + e^{-x}}
σ(x)=1+e−x1 其梯度是:
σ
′
(
x
)
=
σ
(
x
)
⋅
(
1
−
σ
(
x
)
)
\sigma'(x) = \sigma(x) \cdot (1 - \sigma(x))
σ′(x)=σ(x)⋅(1−σ(x))
取值区间: σ ( x ) \sigma(x) σ(x)的输出范围是 ( 0 , 1 ) (0, 1) (0,1)。因此, σ ′ ( x ) \sigma'(x) σ′(x)的取值范围是 ( 0 , 0.25 ] (0, 0.25] (0,0.25]。具体地,当 σ ( x ) \sigma(x) σ(x)接近0或1时, σ ′ ( x ) \sigma'(x) σ′(x)会接近 0;当 σ ( x ) \sigma(x) σ(x)接近0.5时, σ ′ ( x ) \sigma'(x) σ′(x)达到最大值0.25。
(2)双曲正切函数(Tanh)
双曲正切函数定义为: tanh ( x ) = e x − e − x e x + e − x \tanh(x) = \frac{\text e^x - \text e^{-x}}{\text e^x + \text e^{-x}} tanh(x)=ex+e−xex−e−x 其梯度是: tanh ′ ( x ) = 1 − tanh 2 ( x ) \tanh'(x) = 1 - \tanh^2(x) tanh′(x)=1−tanh2(x)
取值区间: tanh ( x ) \tanh(x) tanh(x)的输出范围是 ( − 1 , 1 ) (-1, 1) (−1,1)。因此, tanh ′ ( x ) \tanh'(x) tanh′(x)的取值范围是 ( 0 , 1 ] (0, 1] (0,1]。具体地,当 tanh ( x ) \tanh(x) tanh(x)接近-1或1时, tanh ′ ( x ) \tanh'(x) tanh′(x)会接近0;当 tanh ( x ) \tanh(x) tanh(x)接近0时, tanh ′ ( x ) \tanh'(x) tanh′(x)达到最大值1。
2. MLP的层数对梯度计算的影响
在多层感知机(MLP)的训练过程中,梯度计算会受到多层堆叠的影响,这个现象常被称为梯度消失或梯度爆炸问题。具体来说:
(1)梯度消失:
- 逻辑斯谛函数(Sigmoid)和双曲正切函数(Tanh)的影响: 当使用这些激活函数时,梯度消失问题尤为显著。因为在这些函数的极端区域,梯度会变得非常小(尤其是逻辑斯谛函数,其梯度最大为0.25),这会导致反向传播过程中梯度逐层减小,尤其是在深层网络中,梯度可能会变得非常接近零。这使得前面的层几乎不会收到足够的梯度信号,从而影响网络的学习能力。
(2)梯度爆炸:
- 梯度爆炸: 这是指在反向传播过程中,梯度可能会因为链式法则而逐层放大,尤其是在网络层数很深的情况下。梯度爆炸会导致训练不稳定,参数更新过大,甚至导致模型权重变得非常大。
3. 解决方法
为了缓解梯度消失和梯度爆炸问题,可以采取以下措施:
- 使用合适的激活函数: 如 ReLU(Rectified Linear Unit)和其变种(如 Leaky ReLU、Parametric ReLU)。这些函数在正区间的梯度恒定不变,从而减少了梯度消失的问题。
- 使用正则化方法: 如权重初始化(例如Xavier初始化或He初始化),批归一化(Batch Normalization),以及适当的梯度裁剪(Gradient Clipping)等方法来保持梯度的稳定性。
- 采用更先进的优化算法: 如 Adam 或 RMSprop,这些优化算法通过自适应调整学习率来帮助减轻梯度爆炸的问题。
总的来说,层数增加时,选择合适的激活函数和网络设计策略对于训练深层网络至关重要,以确保网络能够有效学习和更新参数。
小故事
神经网络的雏形出现于1943年,它最初是由神经生理学家麦卡洛克和数学家皮兹为建立人类神经元活动的数学模型而提出的。接下来,罗森布拉特在1958年提出了感知机,这时的感知机只有一层,结构较为简单,本质上仍然是线性模型,无法解决包括异或问题在内的非线性问题。到了1965年,阿列克谢·伊瓦赫年科(Alexey Ivakhnenko)和瓦连京·拉帕(Valentin Lapa)提出了多层感知机的概念,大大提升了神经网络的表达能力。1982年,保罗·韦伯斯(Paul Werbos)将反向传播算法应用到多层感知机上,改善了网络训练的问题。此时的MLP已经具有了现代神经网络的一些特点,例如反向传播、梯度下降优化等等。然而,相比于同时代的支持向量机(在支持向量机中介绍),MLP的数学解释模糊,很难通过数学方法保证其性能,另外神经网络模型的训练需要消耗大量计算资源。而支持向量机数学表达优美,逻辑严谨,计算简单,深受研究者的喜爱。因此,神经网络方面的研究陷入了长时间的沉寂。
2010年左右,随着计算机硬件的进步与GPU算力的提升,神经网络重新出现在人们的视野中,对它的研究也慢慢增多。2012年,用于图像分类的深度神经网络AlexNet(将在卷积神经网络中介绍)在ImageNet比赛中以巨大优势取得了第一名,瞬间点燃了深度神经网络研究的热情。自此以后,机器学习进入了深度学习时代,深度神经网络及其各种应用成为了机器学习的绝对主流。今天,在许多领域,神经网络模型完成智能任务的水平已经超越了人类,例如AlphaGo、AlphaFold、DALL-E、ChatGPT,越来越多的神经网络模型在不断刷新着我们对人工智能的认知。
五、动手实现多层感知机
接下来,我们先手动实现简单的MLP与反向传播算法,再讲解如何使用PyTorch库中的工具直接构造MLP。首先,我们导入必要的库和数据集。本次使用的数据集xor_dataset.csv
是异或数据集,与上文描述的离散异或问题稍有不同,该数据集包含了平面上连续的点。坐标为
(
x
1
,
x
2
)
(x_1,x_2)
(x1,x2)的点的标签是
I
(
x
1
≥
0
)
xor
I
(
x
2
≥
0
)
\mathbb{I}(x_1 \ge 0) \operatorname{xor} \mathbb{I}(x_2 \ge 0)
I(x1≥0)xorI(x2≥0)。因此,当样本在第一、三象限时,其标签为0;在第二、四象限时标签为1。数据分布和标签如图12所示。每一行数据包含3个值,依次为样本的
x
1
x_1
x1,
x
2
x_2
x2和标签
y
y
y。
import numpy as np
import matplotlib.pyplot as plt
# 导入数据集
data = np.loadtxt('xor_dataset.csv', delimiter=',')
print('数据集大小:', len(data))
print(data[:5])
# 划分训练集与测试集
ratio = 0.8
split = int(ratio * len(data))
np.random.seed(0)
data = np.random.permutation(data)
# y的维度调整为(len(data), 1),与后续模型匹配
x_train, y_train = data[:split, :2], data[:split, -1].reshape(-1, 1)
x_test, y_test = data[split:, :2], data[split:, -1].reshape(-1, 1)
接下来,我们开始实现MLP中的具体内容。由于MLP的前馈结构,我们可以将其拆分成许多层。每一层都应当具备3个基本的功能:根据输入计算输出,计算参数的梯度,更新层参数。激活函数可以抽象为单独的一层,不具有参数。为了形式统一,我们先定义基类,再让不同的层都继承基类。
# 基类
class Layer:
# 前向传播函数,根据输入x计算该层的输出y
def forward(self, x):
raise NotImplementedError
# 反向传播函数,输入上一层回传的梯度grad,输出当前层的梯度
def backward(self, grad):
raise NotImplementedError
# 更新函数,用于更新当前层的参数
def update(self, learning_rate):
pass
线性层是MLP中最基本的结构之一,其参数为 W \boldsymbol W W和 b \boldsymbol b b,输入与输出关系为 y = W x + b \boldsymbol y = \boldsymbol W \boldsymbol x + \boldsymbol b y=Wx+b。由于其结构相当于将前后的神经元两两都连接起来,因此又称为全连接层。
class Linear(Layer):
def __init__(self, num_in, num_out, use_bias=True):
self.num_in = num_in # 输入维度
self.num_out = num_out # 输出维度
self.use_bias = use_bias # 是否添加偏置
# 参数的初始化非常重要
# 如果把参数默认设置为0,会导致Wx=0,后续计算失去意义
# 我们直接用正态分布来初始化参数
self.W = np.random.normal(loc=0, scale=1.0, size=(num_in, num_out))
if use_bias: # 初始化偏置
self.b = np.zeros((1, num_out))
def forward(self, x):
# 前向传播y = Wx + b
# x的维度为(batch_size, num_in)
self.x = x
self.y = x @ self.W # y的维度为(batch_size, num_out)
if self.use_bias:
self.y += self.b
return self.y
def backward(self, grad):
# 反向传播,按照链式法则计算
# grad的维度为(batch_size, num_out)
# 梯度要对batch_size取平均
# grad_W的维度与W相同,为(num_in, num_out)
self.grad_W = self.x.T @ grad / grad.shape[0]
if self.use_bias:
# grad_b的维度与b相同,为(1, num_out)
self.grad_b = np.mean(grad, axis=0, keepdims=True)
# 向前传播的grad维度为(batch_size, num_in)
grad = grad @ self.W.T
return grad
def update(self, learning_rate):
# 更新参数以完成梯度下降
self.W -= learning_rate * self.grad_W
if self.use_bias:
self.b -= learning_rate * self.grad_b
除了线性部分以外,MLP中还有非线性激活函数。这里我们只实现上文讲到的逻辑斯谛函数、tanh函数和ReLU函数3种激活函数。为了将其应用到MLP中,除了其表达式,我们还需要知道其梯度,以计算反向传播。它们的梯度分别为:
-
逻辑斯谛函数的梯度在逻辑斯谛回归一文中已经介绍过,这里直接给出结果: ∂ σ ( x ) ∂ x = σ ( x ) ( 1 − σ ( x ) ) \frac{\partial \sigma(x)}{\partial x} = \sigma(x)(1-\sigma(x)) ∂x∂σ(x)=σ(x)(1−σ(x))
-
tanh函数的梯度:
∂ tanh ( x ) ∂ x = ∂ ∂ x ( e x − e − x e x + e − x ) = ( e x + e − x ) 2 − ( e x − e − x ) 2 ( e x + e − x ) 2 = 1 − ( e x − e − x e x + e − x ) 2 = 1 − tanh ( x ) 2 \begin{aligned} \frac{\partial \tanh(x)}{\partial x} &= \frac{\partial}{\partial x}\left(\frac{\text e^x - \text e^{-x}}{\text e^x + \text e^{-x}} \right) \\[2ex] &= \frac{(\text e^x+\text e^{-x})^2 - (\text e^x-\text e^{-x})^2}{(\text e^x+\text e^{-x})^2} \\[2ex] &= 1 - \left(\frac{\text e^x - \text e^{-x}}{\text e^x + \text e^{-x}}\right)^2 \\[2ex] &= 1 - \tanh(x)^2 \end{aligned} ∂x∂tanh(x)=∂x∂(ex+e−xex−e−x)=(ex+e−x)2(ex+e−x)2−(ex−e−x)2=1−(ex+e−xex−e−x)2=1−tanh(x)2 -
ReLU函数是一个分段函数,因此其梯度也是分段的,为: ∂ R e L U ( x ) ∂ x = { 1 ( x ≥ 0 ) 0 ( x < 0 ) \frac{\partial \mathrm{ReLU}(x)}{\partial x} = \begin{cases} 1 \quad (x \ge 0) \\ 0 \quad (x < 0) \end{cases} ∂x∂ReLU(x)={1(x≥0)0(x<0) 事实上, R e L U ( x ) \mathrm{ReLU}(x) ReLU(x)在 x = 0 x=0 x=0 处的梯度并不存在。但为了计算方便,我们人为定义其梯度与右方连续,值为1。
除此之外,我们有时希望激活函数不改变层的输出,因此我们再额外实现恒等函数(identity function)
ϕ
(
x
)
=
x
\phi(x)=x
ϕ(x)=x。这些激活函数都没有具体的参数,因此在实现时update
留空,只需要完成前向和反向传播即可。
class Identity(Layer):
# 单位函数
def forward(self, x):
return x
def backward(self, grad):
return grad
class Sigmoid(Layer):
# 逻辑斯谛函数
def forward(self, x):
self.x = x
self.y = 1 / (1 + np.exp(-x))
return self.y
def backward(self, grad):
return grad * self.y * (1 - self.y)
class Tanh(Layer):
# tanh函数
def forward(self, x):
self.x = x
self.y = np.tanh(x)
return self.y
def backward(self, grad):
return grad * (1 - self.y ** 2)
class ReLU(Layer):
# ReLU函数
def forward(self, x):
self.x = x
self.y = np.maximum(x, 0)
return self.y
def backward(self, grad):
return grad * (self.x >= 0)
# 存储所有激活函数和对应名称,方便索引
activation_dict = {
'identity': Identity,
'sigmoid': Sigmoid,
'tanh': Tanh,
'relu': ReLU
}
接下来,将全连接层和激活函数层依次拼起来,就可以得到一个简单的MLP了。
class MLP:
def __init__(
self,
layer_sizes, # 包含每层大小的list
use_bias=True,
activation='relu',
out_activation='identity'
):
self.layers = []
num_in = layer_sizes[0]
for num_out in layer_sizes[1: -1]:
# 添加全连接层
self.layers.append(Linear(num_in, num_out, use_bias))
# 添加激活函数
self.layers.append(activation_dict[activation]())
num_in = num_out
# 由于输出需要满足任务的一些要求
# 例如二分类任务需要输出[0,1]之间的概率值
# 因此最后一层通常做特殊处理
self.layers.append(Linear(num_in, layer_sizes[-1], use_bias))
self.layers.append(activation_dict[out_activation]())
def forward(self, x):
# 前向传播,将输入依次通过每一层
for layer in self.layers:
x = layer.forward(x)
return x
def backward(self, grad):
# 反向传播,grad为损失函数对输出的梯度
# 将该梯度依次回传,得到每一层参数的梯度
for layer in reversed(self.layers):
grad = layer.backward(grad)
def update(self, learning_rate):
# 更新每一层的参数
for layer in self.layers:
layer.update(learning_rate)
最后,我们可以直接将封装好的MLP当作一个黑盒子使用,并用梯度下降法进行训练。在本例中,异或数据集属于二分类任务,因此我们采用交叉熵损失,具体的训练过程如下。
# 设置超参数
num_epochs = 1000
learning_rate = 0.1
batch_size = 128
eps=1e-7 # 用于防止除以0、log(0)等数学问题
# 创建一个层大小依次为[2, 4, 1]的多层感知机
# 对于二分类任务,我们用sigmoid作为输出层的激活函数,使其输出在[0,1]之间
mlp = MLP(layer_sizes=[2, 4, 1], use_bias=True, out_activation='sigmoid')
# 训练过程
losses = []
test_losses = []
test_accs = []
for epoch in range(num_epochs):
# 我们实现的MLP支持批量输入,因此采用SGD算法
st = 0
loss = 0.0
while True:
ed = min(st + batch_size, len(x_train))
if st >= ed:
break
# 取出batch
x = x_train[st: ed]
y = y_train[st: ed]
# 计算MLP的预测
y_pred = mlp.forward(x)
# 计算梯度∂J/∂y
grad = (y_pred - y) / (y_pred * (1 - y_pred) + eps)
# 反向传播
mlp.backward(grad)
# 更新参数
mlp.update(learning_rate)
# 计算交叉熵损失
train_loss = np.sum(-y * np.log(y_pred + eps) - (1 - y) * np.log(1 - y_pred + eps))
loss += train_loss
st += batch_size
losses.append(loss / len(x_train))
# 计算测试集上的交叉熵和精度
y_pred = mlp.forward(x_test)
test_loss = np.sum(-y_test * np.log(y_pred + eps) - (1 - y_test) * np.log(1 - y_pred + eps)) / len(x_test)
test_acc = np.sum(np.round(y_pred) == y_test) / len(x_test)
test_losses.append(test_loss)
test_accs.append(test_acc)
print('测试精度:', test_accs[-1])
# 将损失变化进行可视化
plt.figure(figsize=(16, 6))
plt.subplot(121)
plt.plot(losses, color='blue', label='train loss')
plt.plot(test_losses, color='red', ls='--', label='test loss')
plt.xlabel('Step')
plt.ylabel('Loss')
plt.title('Cross-Entropy Loss')
plt.legend()
plt.subplot(122)
plt.plot(test_accs, color='red')
plt.ylim(top=1.0)
plt.xlabel('Step')
plt.ylabel('Accuracy')
plt.title('Test Accuracy')
plt.show()
六、用PyTorch库实现多层感知机
下面,我们用另一个常见的机器学习库PyTorch来实现MLP模型。PyTorch是一个功能强大的机器学习框架,包含完整的机器学习训练模块和机器自动求梯度功能。因此,我们只需要实现模型从输入到输出、再计算损失函数的过程,就可以用PyTorch内的工具自动计算损失函数的梯度,再用梯度下降算法更新参数,省去了繁琐的手动计算过程。PyTorch由于其功能强大、结构简单,是目前最常用的机器学习框架之一。在PyTorch中,MLP需要用到的层和激活函数都已提供好,我们只需按照上一节中类似的方法将其组合在一起就可以了。
如果尚未安装PyTorch库,可使用如下命令进行安装。由于官网下载速度很慢,可以通过清华源链接下载PyTorch。
!pip install -i https://pypi.tuna.tsinghua.edu.cn/simple torch
import torch # PyTorch库
import torch.nn as nn # PyTorch中与神经网络相关的工具
from torch.nn.init import normal_ # 正态分布初始化
torch_activation_dict = {
'identity': lambda x: x,
'sigmoid': torch.sigmoid,
'tanh': torch.tanh,
'relu': torch.relu
}
# 定义MLP类,基于PyTorch的自定义模块通常都继承nn.Module
# 继承后,只需要实现forward函数,进行前向传播
# 反向传播与梯度计算均由PyTorch自动完成
class MLP_torch(nn.Module):
def __init__(
self,
layer_sizes, # 包含每层大小的list
use_bias=True,
activation='relu',
out_activation='identity'
):
super().__init__() # 初始化父类
self.activation = torch_activation_dict[activation]
self.out_activation = torch_activation_dict[out_activation]
self.layers = nn.ModuleList() # ModuleList以列表方式存储PyTorch模块
num_in = layer_sizes[0]
for num_out in layer_sizes[1:]:
# 创建全连接层
self.layers.append(nn.Linear(num_in, num_out, bias=use_bias))
# 正态分布初始化,采用与前面手动实现时相同的方式
normal_(self.layers[-1].weight, std=1.0)
# 偏置项为全0
self.layers[-1].bias.data.fill_(0.0)
num_in = num_out
def forward(self, x):
# 前向传播
# PyTorch可以自行处理batch_size等维度问题
# 我们只需要让输入依次通过每一层即可
for i in range(len(self.layers) - 1):
x = self.layers[i](x)
x = self.activation(x)
# 输出层
x = self.layers[-1](x)
x = self.out_activation(x)
return x
接下来,定义超参数,用相同的方式训练PyTorch模型,最终得到的结果与手动实现的相近。
# 设置超参数
num_epochs = 1000
learning_rate = 0.1
batch_size = 128
eps = 1e-7
torch.manual_seed(0)
# 初始化MLP模型
mlp = MLP_torch(layer_sizes=[2, 4, 1], use_bias=True, out_activation='sigmoid')
# 定义SGD优化器
opt = torch.optim.SGD(mlp.parameters(), lr=learning_rate)
# 训练过程
losses = []
test_losses = []
test_accs = []
for epoch in range(num_epochs):
st = 0
loss = []
while True:
ed = min(st + batch_size, len(x_train))
if st >= ed:
break
# 取出batch,转为张量
x = torch.tensor(x_train[st: ed], dtype=torch.float32)
y = torch.tensor(y_train[st: ed], dtype=torch.float32).reshape(-1, 1)
# 计算MLP的预测
# 调用模型时,PyTorch会自动调用模型的forward方法
# y_pred的维度为(batch_size, layer_sizes[-1])
y_pred = mlp(x)
# 计算交叉熵损失
train_loss = torch.mean(-y * torch.log(y_pred + eps) - (1 - y) * torch.log(1 - y_pred + eps))
# 清空梯度
opt.zero_grad()
# 反向传播
train_loss.backward()
# 更新参数
opt.step()
# 记录累加损失,需要先将损失从张量转为numpy格式
loss.append(train_loss.detach().numpy())
st += batch_size
losses.append(np.mean(loss))
# 计算测试集上的交叉熵
# 在不需要梯度的部分,可以用torch.inference_mode()加速计算
with torch.inference_mode():
x = torch.tensor(x_test, dtype=torch.float32)
y = torch.tensor(y_test, dtype=torch.float32).reshape(-1, 1)
y_pred = mlp(x)
test_loss = torch.sum(-y * torch.log(y_pred + eps) - (1 - y) * torch.log(1 - y_pred + eps)) / len(x_test)
test_acc = torch.sum(torch.round(y_pred) == y) / len(x_test)
test_losses.append(test_loss.detach().numpy())
test_accs.append(test_acc.detach().numpy())
print('测试精度:', test_accs[-1])
# 将损失变化进行可视化
plt.figure(figsize=(16, 6))
plt.subplot(121)
plt.plot(losses, color='blue', label='train loss')
plt.plot(test_losses, color='red', ls='--', label='test loss')
plt.xlabel('Step')
plt.ylabel('Loss')
plt.title('Cross-Entropy Loss')
plt.legend()
plt.subplot(122)
plt.plot(test_accs, color='red')
plt.ylim(top=1.0)
plt.xlabel('Step')
plt.ylabel('Accuracy')
plt.title('Test Accuracy')
plt.show()
附:以上文中的数据集及相关资源下载地址:
链接:https://pan.quark.cn/s/a0463a9794ac
提取码:Ji6J