从感知机到神经网络

2020-06-04

写在前面

这篇文章记录了作者对神经网络这方面的知识的一些总结,从具体问题出发,引出感知机的概念,讲述感知机的一些浅显的知识,推导感知机的学习算法,再从感知机的弱点出发,引入多层神经网络,再讲一讲神经网络的误差逆传播学习算法的推.我们希望,通过把神经网络有关的计算向量化,并且从矩阵运算的角度去讲解神经网络,可以让大家对神经网络的学习和理解变得轻松和简.这篇文章既算是作者对所学知识进行固化的尝试,也算是分享和交.

问题的引入

我们得到一个非常大的数据集,这个数据集有三个变量,不妨分别记做$x_1, x_2, y$,然后呢,这里$x_1, x_2$你可以理解为是物体的参数,例如花瓣的长度,根茎的长度,叶片的宽度等等,而$y$则是标签,例如「好」与「坏」,「A类」与「B类」,「阳性」与「阴性」等等,总之是区分物体的一种标签,我们要从这些数据中挖掘信息(数据挖掘),找到一个模型$f$,使得当我们得到一个新样本(原先数据集中没有出现过的样本)记做$(x_{1}^{(\star)}, x_{2}^{(\star)})$,我们可以把$(x_{1}^{(\star)}, x_{2}^{(\star)})$代入函数$f$,再根据$f(x_{1}^{(\star)}, x_{2}^{(\star)})$的值来判断这个新样本的$y$是多少,即,判断这个新的样本到底是属于哪一.我们拿这份数据集中每个点的$x_1$值作为横坐标,$x_2$值作为纵坐标,前几个数据画在二维平面直角坐标系上是这样的:

数据集作图

数据集作图

这个数据集看起来可能是这样的

$x_1$ $x_2$ $y$
$1$ $1$ A
$1$ $2$ A
$2$ $2$ A
$4$ $4$ B
$5$ $4$ B

也就是说我们希望(从数据中学习)得到一个分类模型,记做$f$,仅仅把$x_1, x_2$扔进$f$,$f$就能够告诉我们这个样本是属于A类还是B.你可能会说从图上看在两堆样本中间画一根斜线就能够区分,但是,如今的问题就是,如何量化这条直线,如何自动化地计算这条直线?

感知机模型

从单个神经元的构造我们受到启发,可以考虑把$x_1, x_2$看做是神经元的输入,神经元的输出就是模型的分类结果,后来,这个「单个神经元模型」经过抽象和简化,看起来像是下图所示的样子:

简单的感知机模型图示

简单的感知机模型图示

图中的$\hat{y}$加了个帽子读作y hat,表示是模型对$y$的预测值,具体地,$\hat{y}$的表达式是

$$ \hat{y} = \begin{cases} 1, & \quad \text{if } \omega_0 + \omega_1 x_1 + \omega_2 x_2 \geq 0, \\
0, & \quad \text{otherwise.} \end{cases} $$

那么,$y = f(x_1, x_2)$就是我们的模型了,而$\omega_0, \omega_1, \omega_2$则是模型参数,是要从数据中去寻找.原来数据集当中的「A类」和「B类」,「是」或「否」,「阳性」或者「阴性」这类字符型标签,统统可以编码成数字$1$或者$0$,这样$y$方便和$\hat{y}$比较,这总是可以做得到.距离来说,现假如说$\omega_0, \omega_1, \omega_2$都已经通过某种途径「找到了」也就是确定了,那么假如说我有新的样本$x_1 = 5, x_2 = 6, y = 1$,如果,按照模型$\omega_0 + \omega_1 \times 5 + \omega_2 \times 6 \geq 0$,那么模型相当于输出$1$,也就是$\hat{y}$是$1$,也就是$\hat{y}=y$,也就是说模型是正确的,反正则说明模型在这个样本点上没有判断正确.

为了严谨,我们需要定义一些符号,从而可以进行形式化地推理,假设我们的数据集中有$N$个数据,数据的标号(第几个)组成一个有限集合$D = \{ 1,2,\cdots,N \}$,干脆把$D$看做是数据集也可以,$x_{1}^{(i)}$表示数据集中第$i$个数据(也就是数据表格中的第$i$行)的$x_1$参数的值,$x_{2}^{(i)}$表示数据集中第$i$个数据(也就是数据表格中的第$i$行)的$x_2$参数的值,$y_{(i)}$表示数据集中第$i$个数据的标签值,可以是$1$或者$0.$E$是模型的「经验误差」,具体地,$E$表示该模型在数据集中所有的数据点上累积犯下的「错误」的总和,形式化地,$E$的定义如下

$$ E = \sum_{i \in D} (\hat{y}_i - y_i)^2 $$

式中,$\hat{y}_i$表示模型对于第$i$个数据点$(x_1^{(i)}, x_2^{(i)})$所做出的预测,值可以是$0$或者$1$,具体计算方式见前文,而,$y_i$则是数据的真实标签,被编码为$0$,或者$1$,$\hat{y}_i$和$y_i$一比较,就知道模型在第$i$个数据点上是预测对了还是预测错了,因而,用这样做差再平方求和的方式来定义「经验误差」$E$也是很自然的做法.

而事实上,$\hat{y}_i$的计算,涉及到$\omega_0, \omega_1, \omega_2$这三个模型参数,

$$ \hat{y} = \begin{cases} 1, & \quad \text{if } \omega_0 + \omega_1 x_1 + \omega_2 x_2 \geq 0, \\
0, & \quad \text{otherwise.} \end{cases} $$

又因为$E$和$\hat{y}_i$有关,$\hat{y}_i$又和$\omega_0, \omega_1, \omega_2$相关,也和每个样本点的$x_1, x_2$相关,因而$E$可以看做是关于$\omega_0, \omega_1, \omega_2$的一个函数

$$ E = E(\omega_0, \omega_1, \omega_2) $$

让$E$分别对$\omega_0, \omega_1, \omega_2$求导数可以得到

$$ \begin{align} \nabla E(\omega_0, \omega_1, \omega_2) &= \begin{bmatrix} \frac{\partial}{\partial \omega_0} \\
\frac{\partial}{\partial \omega_1} \\
\frac{\partial}{\partial \omega_2} \end{bmatrix} E(\omega_0, \omega_1, \omega_2) \\
&= \begin{bmatrix} \frac{\partial}{\partial \omega_0} E(\omega_0, \omega_1, \omega_2) \\
\frac{\partial}{\partial \omega_1} E(\omega_0, \omega_1, \omega_2) \\
\frac{\partial}{\partial \omega_2} E(\omega_0, \omega_1, \omega_2) \end{bmatrix} \end{align} $$

以上列向量中的三个分量分别是$E$对三个参数$\omega_0, \omega_1, \omega_2$的偏导数,表示当函数$E$的三个变量分别取$\omega_0, \omega_1, \omega_2$时有这样的变化「趋势」,如果$\omega_0$增加一个单位,那么看起来$E$的函数值要增加$\frac{\partial}{\partial \omega_0} E(\omega_0, \omega_1, \omega_2)$,如果$\omega_1$增加一个单位,那么看起来$E$的函数值要增加$\frac{\partial}{\partial \omega_1} E(\omega_0, \omega_1, \omega_2)$,如果$\omega_2$增加一个单位,那么看起来$E$的函数值要增加$\frac{\partial}{\partial \omega_2} E(\omega_0, \omega_1, \omega_2).这样的关于$E$如何随着$\omega_0, \omega_1, \omega_2$的变化而变化的信息,为我们如何调节$\omega_0, \omega_1, \omega_2$使得$E$尽可能地小提供了理论依据,其实也正是「梯度下降法」的由.比方说,算出来的梯度向量告诉我,当前有这样的增长趋势:看起来$\omega_0$增加$1$会导致$E$下降$-5$,那么由于我希望最小化$E$,所以根据这个信息,我选择给$\omega_0$增加一个较小的值(涉及到学习率),再假如说梯度向量告诉我看起来当前看起来$\omega_1$增加$1$会导致$E$增加$0.2$,那么显然我会选择减小$\omega_1$的值,从而使得$E$也跟着减小,这就是「梯度下降法」的基本思想——根据梯度信息逐步地改变参数值使目标函数值跟着逐步降低.

梯度下降法

以上我们仅仅是利用符号来讨论寻找最合适参数$\omega_0, \omega_1, \omega_2$使得$E$尽可能小的大致思路(梯度下降法),而梯度其实就是导数,因而,具体怎么求$E$对三个参数的导数才是关键,下边我们一起来推导一下,我们先看$E$对$\omega_0$的偏导数,首先,既然是求偏导数,那么另外两个参数$\omega_1, \omega_2$要看做是常数,然后我们展开$E$

$$ E = E(\omega_0) = \sum_{i=1}^{N} (\hat{y}_i - y_i)^2 $$

然后求导数(偏导)

$$ \frac{\partial}{\partial \omega_0} E = \frac{\partial}{\partial \omega_0} \sum_{i=1}^{N} (\hat{y}_i - y_i)^2 $$

高中学过的关于导数的知识告诉我们,$\frac{\mathsf{d}}{\mathsf{d} x} (f_{a}(x) + f_{b}(x)) = \frac{\mathsf{d}}{\mathsf{d}x} f_{a}(x) + \frac{\mathsf{d}}{\mathsf{d}x} f_{b}(x)$,其实也就是导数的线性性质,因此,我们可以把求导运算符移到求和符号里边去

$$ \frac{\partial}{\partial \omega_0} E = \sum_{i=1}^{N} \frac{\partial}{\partial \omega_0} (\hat{y}_i - y_i)^2 $$

在上式中,$(\hat{y}_i - y_i)^2$可看做是关于$\hat{y}_i$的函数,而$\hat{y}_i$又可看做是关于$\omega_0$的函数,因而,$(\hat{y}_i - y_i)^2$实际上是$\omega_0$的复合函数,利用对于复合函数求导的链式法则,我们有

$$ \frac{\partial}{\partial \omega_0} E = \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) \frac{\partial}{\partial \omega_0} (\hat{y}_i - y_i) $$

而$y_i$是来自数据集的,在这里,不管我们怎么变动$\omega_0$,$y_i$都是不动的,所以$y_i$对$\omega_0$的导数其实是$0$,所以我们得到

$$ \frac{\partial}{\partial \omega_0} E = \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) \frac{\partial}{\partial \omega_0} \hat{y}_i $$

涉及到求$\hat{y}_i$对$\omega_0$的导数,在这里,我们不妨把$\hat{y}$看做是一个关于$\omega_0, \omega_1, \omega_2$和$x_1, x_2$的线性函数,也就是把$\hat{y}$看成$\omega_0 + \omega_1 x_1 + \omega_2 x_2$,这样一来,导数就是$\frac{\partial}{\partial \omega_0} \hat{y}_i = 1.

我认为,有必要解释一下,为什么要把$\hat{y}_i$看成$\overrightarrow{\omega}$和$\overrightarrow{x}$的线性组合函数,并且拿这样一个线性组合函数关于$\omega_0$的导数去凑$\hat{y}_i$关于$\omega_0$的导数?这是因为,我们的模型实际上也可以看成是一个符号函数和线性组合函数的复合$\mathop{sign}(\omega_0 + \omega_1 x_1 + \omega_2 x_2)$,并且在这里$\mathop{sign}$的定义是当输入值大于等于$0$时输出$1$,否则输出$0$,我们可以这样看,假如说$y_i$是$1$,而我们的模型却算出来$\hat{y}_i=-1$,说明复合函数的线性组合部分,也就是符号函数的输入,相比正确的值来说,是「偏小了」,这时,我们希望这个导数能起到作用,什么作用呢?就是梯度下降法遵照这个导数的信息会把复合函数$\hat{y}_i = \mathop{sign}(\omega_0 + \omega_1 x_1 + \omega_2 x_2)$的线性组合部分$\omega_0 + \omega_1 x_1 + \omega_2 x_2$的值「往高了拉」,这样,几次修正之后,可能就会让$\omega_0 + \omega_1 x_1 + \omega_2 x_2$的值大过零,从而$\mathop{sign}(\omega_0 + \omega_1 x_1 + \omega_2 x_2)$输出$1$,从而$\hat{y}_i$是$1$,从而结果变得正确,反过来说,如果本来$y_i$是$0$,而$\hat{y}_i$却是$1$,那也说明复合函数$\hat{y}_i$的线性组合部分是算得偏大了,应该「往小了拉」,而那这个线性组合函数关于$\omega$的导数作为$\hat{y}_i$关于$\omega$的导数则正好能起到这样的效果!因此,从实践的意义上来看,对于降低整体误判损失(也就是经验损失$E$),拿$\omega_0 + \omega_1 x_1 + \omega_2 x_2$关于$\omega$的导数来代替$\mathop{sign}(\omega_0 + \omega_1 x_1 + \omega_2 x_2)$关于$\omega$的导数是合理的!

现在我们用$\frac{\partial}{\partial \omega_0} (\omega_0 + \omega_1 x_1 + \omega_2 x_2)$代替$\frac{\partial}{\partial \omega_0} \mathop{sign}(\omega_0 + \omega_1 x_1 + \omega_2 x_2)$得到$\hat{y}_i = 1$,带进原来的式子,得到

$$ \frac{\partial}{\partial \omega_0} E = \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) $$

这就是$E$关于$\omega_0$的导数.

那$E$关于$\omega_1$和$E$关于$\omega_2$的导数呢?其实也是类似的推导过程和类似的道理,一般地,我们有

$$ \begin{align} \frac{\partial}{\partial \omega_1} E = \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) x_1^{(i)} \\
\frac{\partial}{\partial \omega_2} E = \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) x_2^{(i)} \end{align} $$

瞧,这几个梯度公式最后算出来是多么的简洁!

因为$E$关于$\omega_j$的几何意义是,是函数$E$在$\omega_j$那点切线的斜率,也就是看起来$\omega_j$「增加」(主要是增加)$1$个单位,那么$E$函数值会增加多少个单位,所以,为了最小化$E$,我们要「反其道而行之」,也就是假如说,如果导数是正数就说明增加$\omega_j$会使$E$增加,所以我们要减小$\omega_j$,而如果说导数是负数就说明增加$\omega_j$会使$E$减小,所以此时我们反倒增加$\omega_j$,定义学习率$\eta$为一个比较小的正数,例如令$\eta = 0.1$,那么,我们在计算出梯度之后可以按照下列方法更新参数$\omega_j$使得$E$减小

$$ \begin{align} \omega_0 &\leftarrow \omega_0 - \eta \frac{\partial}{\partial \omega_0} E = \omega_0 - \eta \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) \\
\omega_1 &\leftarrow \omega_1 - \eta \frac{\partial}{\partial \omega_1} E = \omega_1 - \eta \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) x_1^{(i)} \\
\omega_2 &\leftarrow \omega_2 - \eta \frac{\partial}{\partial \omega_2} E = \omega_2 - \eta \sum_{i=1}^{N} 2 (\hat{y}_i - y_i) x_2^{(i)} \end{align} $$

而经过这样一次参数更新之后,再按照

$$ \hat{y}_i = \begin{cases} 1, & \quad \text{if } \omega_0 + \omega_1 x_1 + \omega_2 x_2 \geq 0, \\
0, & \quad \text{otherwise.} \end{cases} $$

的方式进行预测(也就是计算$\hat{y}_i$),然后看模型分别在「训练集」和「测试集」上的误差如何,如果在测试集的误差到达或者已经在拐点附件,则可以停止训练,否则则可能需要继续以梯度下降的方式更新参数$\omega_j$(即使用上面列出来的那三个参数更新公式).

感知机的实现

为了验证我们的推理过程,我们将尝试编程实现一个能够工作的感知机模型,同时,为了代码健壮和易读,我们决定尽可能地将运算向量化,即,采用向量化的风格来编写程序,这样做,能够减少很多行代码,也能够减少很多难以察觉的程序漏洞(bugs),并且,程序更加容易被形式化地证明.

首先,假设知道了参数,那么我们不妨将参数写做一个向量$\mathbf{\omega} = [ \omega_0 \ \omega_1 \ \omega_2 ]^{\mathsf{T}}$,然后,将数据集的数据写成矩阵的形式:

$$ \mathbf{x} = \begin{bmatrix} 1 & x_{1}^{(1)} & x_{2}^{(1)} \\
1 & x_{1}^{(2)} & x_{2}^{(2)} \\
\vdots & \vdots & \vdots \\
1 & x_{1}^{(N)} & x_{2}^{(N)} \end{bmatrix} $$

这样一来,计算$\hat{y}_i$的表达式实际上就简洁多了,我们令$\hat{\mathbf{y}} = [ \hat{y}_1 \ \hat{y}_2 \ \cdots \ \hat{y}_N]^{\mathsf{T}}$,那么就有

$$ \hat{\mathbf{y}} = \mathop{sign}(\mathbf{x} \mathbf{\omega}) $$

这个式子展开来写实际上就是

$$ \begin{bmatrix} \hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \\ \end{bmatrix} = \mathop{sign}(\begin{bmatrix} 1 & x_{1}^{(1)} & x_{2}^{(1)} \\ 1 & x_{1}^{(2)} & x_{2}^{(2)} \\ \vdots & \vdots & \vdots \\ 1 & x_{1}^{(N)} & x_{2}^{(N)} \end{bmatrix} \begin{bmatrix} \omega_0 \\ \omega_1 \\ \omega_2 \end{bmatrix}) $$  

其中$\mathop{sign}$函数作用于输入向量的每一个分量,当输入大于等于$0$的时候,输出$1$,否则输出$0.读者可以很容易地借助线性代数的知识理解上面这个式.

而至于参数的更新,可以写成

$$ \begin{align} \omega_0 &\leftarrow \omega_0 - 2 \eta [1 \ 1 \ \cdots \ 1]_{\text{N个1}} ( \begin{bmatrix}\hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \end{bmatrix} - \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_N \end{bmatrix} ) \\ \omega_1 &\leftarrow \omega_1 - 2 \eta [x_1^{(1)} \ x_1^{(2)} \ \cdots \ x_1^{(N)}] ( \begin{bmatrix}\hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \end{bmatrix} - \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_N \end{bmatrix} ) \\ \omega_2 &\leftarrow \omega_2 - 2 \eta [x_2^{(1)} \ x_2^{(2)} \ \cdots \ x_2^{(N)}] ( \begin{bmatrix}\hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \end{bmatrix} - \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_N \end{bmatrix} ) \end{align} $$  

向量化编程的核心思想就是把相似的运算合在一起,事实上,以上3个参数的更新我们也可以一并写成矩阵运算的形式:

$$ \begin{bmatrix} \omega_0 \\ \omega_1 \\ \omega_2 \end{bmatrix} \leftarrow \begin{bmatrix} \omega_0 \\ \omega_1 \\ \omega_2 \end{bmatrix} - 2 \eta \begin{bmatrix} 1 & 1 & \cdots & 1 \\ x_1^{(1)} & x_1^{(2)} & \cdots & x_1^{(N)} \\ x_2^{(1)} & x_2^{(2)} & \cdots & x_2^{(N)} \end{bmatrix} ( \begin{bmatrix}\hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \end{bmatrix} - \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_N \end{bmatrix} ) $$  

其实也就是

$$ \mathbf{\omega} \leftarrow \mathbf{\omega} - 2 \eta \mathbf{x}^{\mathsf{T}} ( \hat{\mathbf{y}}-\mathbf{y} ) $$

这样一看就简洁了很多!而且还容易编程实现.

另外,经验误差函数$E$可以写作

$$ \begin{align} E &= \sum_{i=1}^{N} (\hat{y}_i - y_i)^2 \\ &= \begin{bmatrix} 1 & 1 & \cdots 1 \end{bmatrix}_{\text{N个1}} (\big| \begin{bmatrix}\hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \end{bmatrix} - \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_N \end{bmatrix} \big| )^2 \end{align} $$  

式中的平方运算是指对括号里边差向量的每个元素进行平方运.而准确率可以写作

$$ 1 - \frac{1}{N} \begin{bmatrix} 1 & 1 & \cdots 1 \end{bmatrix}_{\text{N个1}} (\big| \begin{bmatrix}\hat{y}_1 \\ \hat{y}_2 \\ \vdots \\ \hat{y}_N \end{bmatrix} - \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_N \end{bmatrix} \big| ) $$  

该式子实际上是指模型在整个数据集上的准确度,而要计算测试集和准确度或者训练集的准确度,只需相应地将式中的$N$替换为合适的值,并且将向量里的帽子$y$和$y$替换为合适的值即可.

向量化理论知识准备齐全之后我们开始写Mathematica代码,首先定义符号,sign表示符号函数,定义跟上文一致,predict输入数据的资料矩阵和参数向量,输出预测值,也就是$\hat{y}_i$组成的向量,collectedData是数据,realY是真实标签,eta是学习率,newParameter是一次梯度下降法对参数的更正,evolver是一次预测+参数更正的循环过程.

sign[x_List] := Map[sign, x];
sign[x : Except[_List]] := If[x >= 0, 1, 0];
predict[data_, omega_] := sign[data.omega];
collectedData = {
    {1, 1, 1}, 
    {1, 1, 2}, 
    {1, 2, 2}, 
    {1, 4, 4}, 
    {1, 5, 4}
};
realY = {{0}, {0}, {0}, {1}, {1}};
eta = 0.1;
newParameter[oldParameter_, predictedY_] := (
    oldParameter - 2*eta*(Transpose[collectedData].(predictedY - realY))
);

evolver[misc_] := (
    lastParameter = misc["parameter"];
    lastPredicted = misc["predicted"];
    currentParameter = newParameter[lastParameter, lastPredicted];
    currentPredicted = predict[collectedData, currentParameter];
    Association[{
        "parameter" -> currentParameter,
        "predicted" -> currentPredicted
    }]
);

符号定义好了之后开始设置初始参数,通过NestList函数反复调用evolver完成模型参数的学习

initialParameter = {{0}, {0}, {0}};
initialPredict = predict[collectedData, initialParameter];
initialMisc = Association[{
    "parameter" -> initialParameter,
    "predicted" -> initialPredict
}];
evolutions = NestList[evolver, initialMisc, 18]

以下是程序输出

{
    <| parameter -> { {0},{0},{0} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-0.6},{-0.8},{-1.} }, predicted -> { {0},{0},{0},{0},{0} } |>,
    <| parameter -> { {-0.2},{1.},{0.6} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-0.8},{0.2},{-0.4} }, predicted -> { {0},{0},{0},{0},{0} } |>,
    <| parameter -> { {-0.4},{2.},{1.2} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-1.},{1.2},{0.2} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-1.6},{0.4},{-0.8} }, predicted -> { {0},{0},{0},{0},{0} } |>,
    <| parameter -> { {-1.2},{2.2},{0.8} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-1.8},{1.4},{-0.2} }, predicted -> { {0},{0},{1},{1},{1} } |>,
    <| parameter -> { {-2.},{1.},{-0.6} }, predicted -> { {0},{0},{0},{0},{1} } |>,
    <| parameter -> { {-1.8},{1.8},{0.2} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-2.4},{1.},{-0.8} }, predicted -> { {0},{0},{0},{0},{0} } |>,
    <| parameter -> { {-2.},{2.8},{0.8} }, predicted -> { {1},{1},{1},{1},{1} } |>,
    <| parameter -> { {-2.6},{2.},{-0.2} }, predicted -> { {0},{0},{1},{1},{1} } |>,
    <| parameter -> { {-2.8},{1.6},{-0.6} }, predicted -> { {0},{0},{0},{1},{1} } |>,
    <| parameter -> { {-2.8},{1.6},{-0.6} }, predicted -> { {0},{0},{0},{1},{1} } |>,
    <| parameter -> { {-2.8},{1.6},{-0.6} }, predicted -> { {0},{0},{0},{1},{1} } |>,
    <| parameter -> { {-2.8},{1.6},{-0.6} }, predicted -> { {0},{0},{0},{1},{1} } |>,
    <| parameter -> { {-2.8},{1.6},{-0.6} }, predicted -> { {0},{0},{0},{1},{1} } |>
}

可以看到大概在第14次循环时正确率已经达到100%了,模型已经能够正确区分所有训练集.

感知机的局限

到目前为止,这个感知机模型好像还没有出现什么问题,但假如我我们改变一下数据集,比如说让数据集变成这个样子:

$x_1$ $x_2$ $y$
1 1 0
1 2 1
2 2 0
2 1 1

然后我们再把这4个数据点画出来

线性不可分问题的数据集图示

线性不可分问题的数据集图示

会看到,感知机三个参数$\omega_0, \omega_1, \omega_2$所能够确定的那个用来划分样本集的平面(在二维空间里是直线)不论怎么移动,不论旋转什么角度,都不能够将类别为$0$的样本(在图中用圈表示)和类别为$1$的样本(在图中用叉表示)完全区分开来,这并不像我们一开始遇到的那个「画一条直线就可以区分」的数据.并且,进行实践也会发现,用这样的数据集来训练感知机模型,得到的精确度也始终不会令人满意(最高只有66%左右,至少始终有1/3的样本是被误分类的).

像这样的数据集,它不能简单地用一个超平面来区分类别,就叫做是线性不可分的数据.简单的感知机模型对此类数据集无能为力,也就是说我们无法用感知机模型对此类数据集中的$y$和$x_1, x_2$的关系进行精确的建模.

多层感知机

考虑这样一个函数

$$ f(x_1, x_2) = x_1 \ \text{XOR} \ x_2 $$

其中$(x_1, x_2) \in \{ 0, 1 \} \times \{ 0, 1 \}$,而$\text{XOR}$被称作「异或」运算:当两个输入相等时输出$1$,否则输出$0.把这个函数$f$的输出记为$y$,我们可以遍历这个函数$f$的所有输入和输出

$x_1$ $x_2$ $y$
0 0 0
1 0 1
1 1 0
0 1 1

如果我们将$x_1$作为横坐标,$x_2$作为纵坐标,$y=0$视为圆形,$y=1$视为叉,那么我们可以可视化这个函数

XOR 函数的输出

XOR 函数的输出

从图像上看这其实和前面所说的那个「线性不可分数据集」有点类似,并且,实际上这确实是个线性不可分数据集,因为你也可以看到不存在这样一条直线一次性地把叉和圈区分开.也可以说:$\text{XOR}$运算的所有输入输出组成的数据集是线性不可分的,或者干脆说$\text{XOR}$是线性不可分.但如果我说,$\text{XOR}$运算可以被表示成几个线性可分的运算的组合呢?事实上

$$ x_1 \text{ XOR } x_2 = (x_1 \text{ NAND } x_2) \text{ AND } (x_1 \text{ OR } x_2) $$

这里,$\text{AND}$表示「与」运算,仅当两个输入都是$1$时,输出$1$,否则输出$0$,$\text{NAND}$表示「与非」运算,仅当两个输入都为$1$时,输出$0$,$\text{OR}$表示「或」运算,仅当两个输入都为$0$时输出$0$,否则输出$1.将这三个运算的图像画出来,如下

「与」运算

「与」运算

「与非」运算

「与非」运算

「或」运算

「或」运算

回想我们学习过的「感知机」,如果一个数据集是线性可分的,那么,我们可以找到一个「超平面」把这个数据集中的一类和另一类区分开来,这里,「与」运算,「与非」运算,「或」运算的输入都是2维的,在二维空间中「超平面」实际上就是一条直线,也就是说我们可以找到一条直线,把「与」运算产生的数据集中的标签$y$为$0$和$y$为$1$的两类数据划分开来,对于「与非」运算产生的数据集,以及对于「或」运算产生的数据集同理.

观察图形可知,划分「与」运算数据集两类数据的那条直线的方程大概为

$$ x_1 + x_2 = 1.5 $$

为了应用感知机模型的知识,我们可以将上式写为

$$ x_1 + x_2 - 1.5 = 0 $$

并且说,对于来自$\{0,1 \} \times \{0 , 1 \}$的输入$(x_1, x_2)$,当

$$ x_1 + x_2 - 1.5 \geq 0 $$

的时候,$y$应被「预测」为$1$,而当

$$ x_1 + x_2 - 1.5 < 0 $$

的时候,$y$应被「预测」为$0.观察感知机模型的预测方式,

$$ \hat{y}_i = \begin{cases} 1, & \quad \text{if } \omega_0 + \omega_1 x_1 + \omega_2 x_2 \geq 0, \\
0, & \quad \text{otherwise.} \end{cases} $$

我们可以直接写出用来对「与」运算产生的数据集建模的感知机模型的三个参数$\omega_0 = -1.5, \omega_1 = 1, \omega_2 = 1.同理,对于「与非」运算产生的数据集,从图像上观察可知,当

$$ x_1 + x_2 - 1.5 < 0 $$

的时候$y$应被「预测」为$1$,当

$$ x_1 + x_2 - 1.5 \geq 0 $$

的时候$y$应被「预测」为$0$,写成感知机的参数,也就是$\omega_0 = 1.5, \omega_1 = -1, \omega_2 = -1.再去观察「或」运算的图像,当

$$ x_1 + x_2 - 0.5 \geq 0 $$

的时候可把$y$预测为$1$,那么,感知机的参数就是$\omega_0 = -0.5, \omega_1 = 1, \omega_2 = 1$.

写到这里,我们发现,虽然说,我们不能够用感知机模型直接对「异或」运算产生的数据集进行建模,但是我们可以间接地,先将「异或」运算展开成几个线性可分运算(能产生线性可分数据集的运算)的复合(我们展开$\text{XOR}$时用到了$\text{NAND}$,$\text{AND}$,和$\text{OR}$),然后,再分别对这几个用来展开$\text{XOR}$的运算用感知机来进行建模,从而间接地实现了,用感知机对「异或」运算进行建模.

用数学的语言,我们可以将$\text{XOR}$,$\text{NAND}$,$\text{AND}$,$\text{OR}$都看做是二元函数,那么对于$(x_1, x_2) \in \{ 0, 1 \} \times \{ 0, 1 \}$有以下式子

$$ \text{XOR}(x_1, x_2) = \text{AND}(\text{NAND}(x_1, x_2), \text{OR}(x_1, x_2)) $$

当要用到感知机来对$\text{XOR}$进行建模的时候,我们先分别用$f_{\text{AND}}$,$f_{\text{NAND}}$,和$f_{\text{OR}}$对$\text{AND}$,$\text{NAND}$,和$\text{OR}$进行建模

$$ \begin{align} f_{\text{AND}}(x_1, x_2) &= \text{AND}(x_1, x_2) \\ f_{\text{NAND}}(x_1, x_2) &= \text{NAND}(x_1, x_2) \\ f_{\text{OR}}(x_1, x_2) &= \text{OR}(x_1, x_2) \end{align} $$  

然后通过将$\text{XOR}$的感知机模型$f_{\text{XOR}}$表示成$f_{\text{AND}}$,$f_{\text{NAND}}$,和$f_{\text{OR}}$的组合

$$ f_{\text{XOR}}(x_1, x_2) = f_{\text{AND}}(f_{\text{NAND}}(x_1, x_2), f_{\text{OR}}(x_1, x_2)) $$

来实现用感知机模型对$\text{XOR}$进行间接建模.

到这里,我们已经明白了,用感知机模型虽然不能直接对线性不可分的函数进行建模,但是可以间接地,将要被建模的线性不可分函数展开成线性函数的组合,对这些线性可分函数,一个个地用感知机进行建模,再按照原来的展开方式,将这些线性可分函数的感知机模型复合成一个新的模型,从而实现对线性不可分函数的间接建模.

感知机模型参照的是智慧生物大脑中的单个神经元细胞,单个神经元细胞有树突,树突有很多分叉,相当于感知机模型的多个输入,还有轴突,轴突相当于感知机的输出,在智慧生物的大脑中,轴突通过突触连接至下一个神经元的树突,实现了神经元和神经元的连接,相当于感知机的输出连接到另外一个感知机的输入,可以说,这样复合而成的感知机(相互连接的神经元)被叫做神经网络,神经网络是复合感知机的更一般且更通用的表示.

对于$\text{XOR}$函数,我们已经有了一个对应的复合感知机$f_{\text{XOR}}(x_1, x_2) = f_{\text{AND}}(f_{\text{NAND}}(x_1, x_2), f_{\text{OR}}(x_1, x_2))$,下面,就让我们用神经网络的形式,将这个复合感知机表示出来吧,首先,我们从复合函数的最内层开始,我们先画一个$f_{\text{NAND}}$感知机的图形表示:

NAND感知机图示

NAND感知机图示

再画一个$f_{\text{OR}}$感知机图示

OR感知机图示

OR感知机图示

再画一个$f_{\text{AND}}$感知机图示

AND感知机图示

AND感知机图示

最后只要按照函数复合的结构将这三个感知机拼接起来就好啦,每个感知机都有一个输出和两个输入,正好$f_{\text{NAND}}$,$f_{\text{OR}}$,和$f_{\text{AND}}$也分别都有两个输入和一个输出,如图:

XOR感知机图示

XOR感知机图示

图中最右边的那个其实就是$\text{AND}$感知机的输出,中间那一层其实就是$\text{AND}$感知机的输入,而中间那一层其实也是$\text{NAND}$感知机的输出和$\text{OR}$感知机的输出(你可以从直线所代表的参数看出),函数的复合正好就是感知机的连接!

多层感知机的实现

对于一个多层感知机,假如说我们已经知道了这个感知机所有相邻两层的神经元之间的连接权重(也即感知机的参数$\omega$,也即我们在直线上标示的数字),要用应用该多层感知机对得到的数据进行预测,只需按部就班地,按照函数复合的顺序依次将数值计算出来再做判断就可以了,下面,我给出应用多层感知机做预测的Mathematica代码

sign[x : Except[_List]] := If[x >= 0, 1, 0];
sign[x_List] := Map[sign, x];

predict[w12_, w23_, input_] := (
    layer2Input = weight12.input;
    layer2Output = sign[layer2Input];
    layer2Bias = {Table[1, Dimensions[input][[2]]]};
    layer2RealOutput = Join[layer2Bias, layer2Output];
    layer3Input = weight23.layer2RealOutput;
    layer3Output = sign[layer3Input];
    layer3Output
);

weight12 = {{1.5, -1, -1}, {-0.5, 1, 1}};
weight23 = {{-1.5, 1, 1}};
data = {{1, 1, 1, 1}, {0, 0, 1, 1}, {0, 1, 1, 0}};
predict[weight12, weight23, data]

输出结果是

{{0, 1, 0, 1}}

这里可能有必要说明一下,代码中weight12表示第一层和第二层的连接权重,这是一个矩阵,这个矩阵的第$i$行第$j$列表示第一层的第$j$个神经元到第二层的第$i$个神经元的权重(不算第二层的偏置神经元.同理,weight23表示第二层和第三层的链接权重,同样地,weight23的第$i$行第$j$列表示第二层的第$j$个神经元(包括偏置神经元)到第三层的第$i$个神经元的连接权重,读者可对照上一节画出的$\text{XOR}$感知机模型的示意图帮助理.这里的data也是一个矩阵,每一列表示一个样本,第一行总是对应多层感知机中的偏置单.输出结果的第$n$列也对应到输入的第$n$列,现在输入是$4$个样本(data矩阵是$4$列),输出也是$4$列,输出是{{0, 1, 0, 1}},表示,对应$(0, 0)$的输出是$0$,对应$(0, 1)$的输出是$1$,对应$(1, 1)$的输出是$0$,对应$(1, 0)$的输出是$1$,正好就是$\text{XOR}$运算的输出,可以看到,我们的感知机成功地拟合了$\text{XOR}$运算.

寻找多层感知机的参数

但假如说我们不知道多层感知机的参数呢?假如说我们不知道相邻两层之间神经元和神经元的连接权重,那我们希望,能够由算法自动地根据数据「计算」出最合适的参数,使得在这样的参数下,多层感知机模型对已知数据做出的误判次数非常的.

具体地,还是仿照我们寻找感知机模型的参数的时候用到的思路,定义一个经验误差函数$E = E(\mathbf{\omega}, D)$表示参数为$\mathbf{\omega}$的多层感知机在数据集$D$上所犯下的错误的总和,其中$\mathbb{\omega}$是存储着参数的多维数组或者多维矩阵,$D$是数据集,我们把模型对于输入$\mathbf{x}$的输出记为$f(\mathbf{x})$,把对应$\mathbf{x}$的真实标签记为$t(\mathbf{x})$,那么$E$可以写作

$$ E(\mathbf{\omega}, D) = \sum_{\mathbf{x} \in D} (f_{\mathbf{\omega}}(\mathbf{x}) - t(\mathbf{x}))^2 $$

给定$D$,那么最优参数$\mathbf{\omega}^{\star}$的确定方式就是

$$ \mathbf{\omega}^{\star} = \mathrm{arg}\underset{\mathbf{\omega}}\min E(\omega, D) $$

其实$\mathbf{\omega}^{\star}$也就是在所有可能的$\mathbf{\omega}$中使得$E$最小的那个.

寻找这样一个最优参数$\mathbf{\omega}^{\star}$的一个简单直接的方式就是梯度下降法,也就是说要先计算梯度,梯度其实也就是要优化的函数对参数的导数,我们实际上要求$E$关于9个参数的梯度,其中有第一层到第二层的神经元的连接权重,$\omega_{i,j}^{(1)}$表示第一层的第$j$个神经元到第二层的第$i$个神经元的连接权重,$j=0$代表第一层的偏置神经元,$i=0$代表第二层的偏置神经元.

$$ \omega^{(1)} = \begin{bmatrix} \omega_{1,0}^{(1)} & \omega_{1,1}^{(1)} & \omega_{1,2}^{(1)} \\ \omega_{2,0}^{(1)} & \omega_{2,1}^{(1)} & \omega_{2,2}^{(1)} \end{bmatrix} $$  

没有出现$i=0$的权重表明是没有神经元的输出被作为第二层偏置神经元的输.第二层到第三层的权重为

$$ \omega^{(2)} = \begin{bmatrix} \omega_{1,0}^{(2)} & \omega_{1,1}^{(2)} & \omega_{1,2}^{(2)} \end{bmatrix} $$   多层感知机的参数示意图

多层感知机的参数示意图

假如说我得到了某个样本,$\mathbf{x}^{(i)}=[x_1^{(i)} \ x_2^{(i)}]^{\mathsf{T}}$,那么我们可以立刻得到第一层的输入

$$ I_1 = \mathbf{x}^{(i)} = \begin{bmatrix} x_1^{(i)} \\ x_2^{(i)} \end{bmatrix} $$  

第一层仅仅是给输入补充一个恒为$1$的偏置分量,等于是什么都不做,第一层的输出也就是

$$ O_1 = \begin{bmatrix} 1 \\ \text{identity}(\mathbf{x}^{(i)}) \end{bmatrix} = \begin{bmatrix} 1 \\ x_1^{(i)} \\ x_2^{(i)} \end{bmatrix} $$  

第一层的输出经过加权求和变为第二层的输入

$$ I_2 = \omega^{(1)} O_1 = \begin{bmatrix} \omega_{1,0}^{(1)} & \omega_{1,1}^{(1)} & \omega_{1,2}^{(1)} \\ \omega_{2,0}^{(1)} & \omega_{2,1}^{(1)} & \omega_{2,2}^{(1)} \end{bmatrix} \begin{bmatrix} 1 \\ x_1^{(i)} \\ x_2^{(i)} \end{bmatrix} $$  

第二层的输入经过符号函数,再补充偏置单元,变成第二层的输出

$$ O_2 = \begin{bmatrix} 1 \\ \text{sign}(I_2) \end{bmatrix}_{3 \times 1} $$  

第二层的输出$O_2$是3行1列的,因为第二层的输入$I_2$是2行1列.第三层的输入$I_3$又由第二层的输出$O_2$和第二层到第三层的连接权重$\omega^{(2)}$加权求和计算

$$ I_3 = \omega^{(2)} O_2 = \begin{bmatrix} \omega_{1,0}^{(2)} & \omega_{1,1}^{(2)} & \omega_{1,2}^{(2)} \end{bmatrix} \begin{bmatrix} 1 \\ \text{sign}(I_2) \end{bmatrix} $$  

注意式中$I_2$是3行1列的,而$\text{sign}$仅仅是分别对每个分量同时进行运算,所以这两个矩阵维数是匹配的,是可以做矩阵乘法.$I_3$是1行1列的,是个标.第三层的输出$O_3$就是最终输出了

$$ O_3 = \text{sign}(I_3) $$

以上我们明白了信息如何从多层感知机的输入层逐步传递到输出层,对于一个样本$\mathbf{x}^{(i)} = [x_1^{(i)} \ x_2^{(i)}]$,记多层感知机的输出为$f(\mathbf{\omega}, \mathbf{x}^{(i)}) = O_3$,我们还可以把$f(\mathbf{\omega}, \mathbf{x}^{(i)})$直接写成复合函数的形式:

$$ \begin{align} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= f_{\text{AND}}(f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)}), f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})) \\ &= \text{sign}(\omega_{1,0}^{(2)} + \omega_{1,1}^{(2)} f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)}) + \omega_{1,2}^{(2)} f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})) \end{align} $$   $$ f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)}) = \text{sign}(\omega_{1,0}^{(1)} + \omega_{1,1}^{(1)} x_1^{(i)} + \omega_{1,2}^{(1)} x_2^{(i)}) $$   $$ f_{\text{OR}}(x_1^{(i)}, x_2^{(i)}) = \text{sign}(\omega_{2,0}^{(1)} + \omega_{2,1}^{(1)} x_1^{(i)} + \omega_{2,2}^{(1)} x_2^{(i)}) $$  

明白了多层感知机不过是函数的复合这一事实之后,求导其实也就非常容易了,我们现在先来求$f(\mathbf{\omega}, \mathbf{x}^{(i)})$对9个$\omega_{i,j}^{(n)}$各自的导数,待会求经验误差函数对参数$\mathbf{\omega}$的梯度(也就是导数)$\nabla E(\mathbf{\omega})$的时候会用到,遇到符号函数$\text{sign}$,我们仿照之前求感知机对$\omega$的导数时用过的方法——将$\text{sign}$视作恒等函数(输出直接等于输入的函数),只考虑$\text{sign}$内部的求和运算,也就是把整个$\text{sign}(\mathbf{\omega} \mathbf{x})$视作$\mathbf{\omega} \mathbf{x}$,这样求出来的导数对于使用梯度下降法降低$E$有帮助.

$$ \frac{\partial}{\partial \omega_{1,0}^{(2)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) = 1 $$   $$ \begin{align} \frac{\partial}{\partial \omega_{1,1}^{(2)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)}) \\ &= \text{sign}(\omega_{1,0}^{(1)} + \omega_{1,1}^{(1)} x_1^{(i)} + \omega_{1,2}^{(1)} x_2^{(i)}) \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{1,2}^{(2)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= f_{\text{OR}}(x_1^{(i)}, x_2^{(i)}) \\ &= \text{sign}(\omega_{2,0}^{(1)} + \omega_{2,1}^{(1)} x_1^{(i)} + \omega_{2,2}^{(1)} x_2^{(i)}) \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{1,0}^{(1)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= \frac{\partial f(\mathbf{\omega}, \mathbf{x}^{(i)})}{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})} \cdot \frac{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{1,0}^{(1)}} \\ &= \omega_{1,1}^{(2)} \cdot \frac{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{1,0}^{(1)}} \\ &= \omega_{1,1}^{(2)} \cdot 1 \\ &= \omega_{1,1}^{(2)} \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{1,1}^{(1)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= \frac{\partial f(\mathbf{\omega}, \mathbf{x}^{(i)})}{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})} \cdot \frac{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{1,1}^{(1)}} \\ &= \omega_{1,1}^{(2)} \cdot \frac{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{1,1}^{(1)}} \\ &= \omega_{1,1}^{(2)} \cdot x_1^{(i)} \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{1,2}^{(1)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= \frac{\partial f(\mathbf{\omega}, \mathbf{x}^{(i)})}{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})} \cdot \frac{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{1,2}^{(1)}} \\ &= \omega_{1,1}^{(2)} \cdot \frac{\partial f_{\text{NAND}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{1,2}^{(1)}} \\ &= \omega_{1,1}^{(2)} \cdot x_2^{(i)} \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{2,0}^{(1)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= \frac{\partial f(\mathbf{\omega}, \mathbf{x}^{(i)})}{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})} \cdot \frac{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{2,0}^{(1)}} \\ &= \omega_{1,2}^{(2)} \cdot \frac{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{2,0}^{(1)}} \\ &= \omega_{1,2}^{(2)} \cdot 1 \\ &= \omega_{1,2}^{(2)} \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{2,1}^{(1)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= \frac{\partial f(\mathbf{\omega}, \mathbf{x}^{(i)})}{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})} \cdot \frac{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{2,1}^{(1)}} \\ &= \omega_{1,2}^{(2)} \cdot \frac{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{2,1}^{(1)}} \\ &= \omega_{1,2}^{(2)} x_1^{(i)} \end{align} $$   $$ \begin{align} \frac{\partial}{\partial \omega_{2,2}^{(1)}} f(\mathbf{\omega}, \mathbf{x}^{(i)}) &= \frac{\partial f(\mathbf{\omega}, \mathbf{x}^{(i)})}{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})} \cdot \frac{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{2,2}^{(1)}} \\ &= \omega_{1,2}^{(2)} \cdot \frac{\partial f_{\text{OR}}(x_1^{(i)}, x_2^{(i)})}{\partial \omega_{2,2}^{(1)}} \\ &= \omega_{1,2}^{(2)} \cdot x_2^{(i)} \end{align} $$  

有了这些,求$E$的梯度就非常容易了

$$ \begin{align} \frac{\partial E}{\partial \omega_{1,0}^{(2)}} &= \frac{\partial}{\partial \omega_{1,0}^{(2)}} \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x}))^2 \\ &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \frac{\partial}{\partial \omega_{1,0}^{(2)}} f(\mathbf{\omega}, \mathbf{x}) \\ &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \end{align} $$

事实上,一般地,都有

$$ \begin{align} \frac{\partial E}{\partial \omega_{i,j}^{(n)}} &= \frac{\partial}{\partial \omega_{i,j}^{(n)}} \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x}))^2 \\ &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \frac{\partial}{\partial \omega_{i,j}^{(n)}} f(\mathbf{\omega}, \mathbf{x}) \end{align} $$

因此

$$ \begin{align} \frac{\partial E}{\partial \omega_{1,0}^{(2)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot O_{2,1}(\mathbf{x}) \\ \frac{\partial E}{\partial \omega_{1,1}^{(2)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot O_{2,2}(\mathbf{x}) \\ \frac{\partial E}{\partial \omega_{1,2}^{(2)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot O_{2,3}(\mathbf{x}) \\ \frac{\partial E}{\partial \omega_{1,0}^{(1)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot \omega_{1,1}^{(2)} x_0 \\ \frac{\partial E}{\partial \omega_{1,1}^{(1)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot \omega_{1,1}^{(2)} x_1 \\ \frac{\partial E}{\partial \omega_{1,2}^{(1)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot \omega_{1,1}^{(2)} x_2 \\ \frac{\partial E}{\partial \omega_{2,0}^{(1)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot \omega_{1,2}^{(2)} x_0 \\ \frac{\partial E}{\partial \omega_{2,1}^{(1)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot \omega_{1,2}^{(2)} x_1 \\ \frac{\partial E}{\partial \omega_{2,2}^{(1)}} &= 2 \sum_{\mathbf{x} \in D} (f(\mathbf{\omega}, \mathbf{x}) - t(\mathbf{x})) \cdot \omega_{1,2}^{(2)} x_2 \end{align} $$  

式中,$O_{2,1}(\mathbf{x}), O_{2,2}(\mathbf{x}), O_{2,3}(\mathbf{x})$分别表示当多层感知机的输入为$\mathbf{x}$的时候,第二层第1,2,3个神经元的输出,$x_0$总是表示第一层的偏置节点,$x_0$恒为$1$(所以也叫哑节点),而$O_{2,1}(\mathbf{x})$其实也就是第二层的哑节点,也是恒为$1$,定义了这样的哑节点,是为了让梯度的表达式看起来更加整洁统一一.并且,显然有

$$ \begin{align} O_{2,2}(\mathbf{x}) &= \text{sign}(\omega_{1,0}^{(1)} + \omega_{1,1}^{(1)} x_1 + \omega_{1,2}^{(1)} x_2) \\ O_{2,3}(\mathbf{x}) &= \text{sign}(\omega_{2,0}^{(1)} + \omega_{2,1}^{(1)} x_1 + \omega_{2,2}^{(1)} x_2) \end{align} $$  

至此,对于这个用来拟合$\text{XOR}$函数的多层感知机模型$f_{\text{XOR}}$我们已经熟悉得不能再熟悉了,知道了梯度,接下来我们可以开始实现多层感知机的学习算法,有许多种学习算法可用来确定多层感知机(神经网络)的参数,我们接下来实现的这一种,主要的策略的最小化多层感知机的经验误差函数,通过考虑经验误差函数对参数的导数(梯度),在每一次迭代中,根据梯度决定如何调整参数使经验误差函数的函数值减小,有的人也把这种算法称作「误差逆传播批量梯度下降法」(BPBGD, Back Propagation Batch Gradient Descent):

首先我们需要实现一些函数单独地计算每一层的输出,这里,我们用来建模$\text{XOR}$函数的多层感知机模型$f_{\text{XOR}}$实际上是有3层,第一层输入层,什么都不做,就仅仅是加了个哑节点传递到下一层(第二层),第二层把原始输入转化为$\text{NAND}$输出和$\text{OR}$输出,具体是先对来自第一层的输出进行加权求和分别分配到第二层的两个节点(不包括第二层的哑节点),然后对加权求和后得到的向量批量运用符号函数,计算完符号函数后加上一个哑节点作为第二层的输出,第三层接受第二层的输出,具体是加权,作为第三层的输入,然后第三层的输出就是把第三层的输入代入符号函数:

layer1Input[input_] := input;
layer1Output[input_] := Join[{{1}}, input];

layer2Input[input_, weights_] := weights.input;
sign[x : Except[_List]] := If[x >= 0, 1, 0];
sign[x_List] := Map[sign, x];
layer2Output[input_] := Join[{{1}}, sign[input]];

layer3Input[input_, weights_] := weights.input;
layer3Output[input_] := sign[input][[1]][[1]];

dataSet = {{{0}, {0}}, {{0}, {1}}, {{1}, {1}}, {{1}, {0}}};

predictor[weights_] := Composition[
    layer3Output,
    layer3Input[#, weights[[2]]] &,
    layer2Output,
    layer2Input[#, weights[[1]]] &,
    layer1Output,
    layer1Input
];

按照我们前面推导出来的梯度,我们打算单独计算对应到每个样本的梯度,然后再对给定数据集的所有样本求和,得到在给定数据集上的总梯度:

gradients[singleData_, weights_, real_] := Module[
    {
        l1o,
        l2o,
        l3o,
        pW
    },
    (
        l1o = layer1Output[layer1Input[singleData]];
        l2o = layer2Output[layer2Input[l1o, weights[[1]]]];
        l3o = layer3Output[layer3Input[l2o, weights[[2]]]];

        {
            Transpose[
                2*(l3o - real)*Table[Drop[weights[[2]][[1]], 1], 3]*
                Join[l1o, l1o, 2]
            ],
            Transpose[2*(l3o - real)*l2o]
        }
    )
];

trueLabels = {0, 1, 0, 1};

randomInitialWeights[] := {
    RandomInteger[{0, 1}, {2, 3}],
    RandomInteger[{0, 1}, {1, 3}]
};

gradientsByIndexes[weights_, indexes_] := Fold[
    Plus, 
    Table[
        gradients[
            dataSet[[i]], weights, trueLabels[[i]]
        ], 
        {i, indexes}
    ]
];

设定eta = 0.05为默认的学习率,按照梯度下降法参数的更新公式

下一轮迭代时的参数 = 当前参数 - 学习率 * 梯度

代码如下


eta = 0.05;

weightsEvolver[modelInfo_, indexes_, trueLabels_] := Module[
    {
        thisWeights,
        thisPredicts,
        thisErrors
    },
    (
        thisWeights = modelInfo["weights"] - 
        eta*gradientsByIndexes[modelInfo["weights"], indexes];

        thisPredicts = Map[predictor[thisWeights], dataSet];
        thisErrors = Total[(trueLabels - thisPredicts)^2];

        Association[{
            "weights" -> thisWeights,
            "predicts" -> thisPredicts,
            "errors" -> thisErrors
        }]
    )
];

evolver[modelInfo_] := weightsEvolver[
    modelInfo, {1, 2, 3, 4}, {0, 1, 0, 1}
];

梯度下降法需要有一个初始的参数,而在不同的初始参数下,训练程序的运行过程很不一样,在有的初始参数下,误差根本就不收敛,而在有的初始参数下,误差是可以收敛的,我们的参数在Mathematica中是按照数组来存储的

参数 = {
    {
        {w[1,1,0], w[1,1,1], w[1,1,2]},
        {w[1,2,0], w[1,2,1], w[1,2,2]}
    },
    {
        {w[2,1,0], w[2,1,1], w[2,1,2]}
    }
}

其中的每一个都是在$[0, 1]$均匀分布的随机整数


trial[weights_] := Module[
    {
        modelInfo,
        modelEvolutions
    },
    (
        modelInfo = Association[{"weights" -> weights}];
        modelEvolutions = NestList[evolver, modelInfo, 100];
        modelEvolutions
    )
];

modelErrors[evolutions_] := Cases[
    Map[#["errors"] &, evolutions], 
    Except[_Missing]
];

trialLogs = Table[trial[randomInitialWeights[]], 100];
errorLogs = Map[modelErrors, trialLogs];

Map[ListLinePlot, Take[errorLogs, 10]]
convergeStats = Map[Last[#]["errors"] == 0 &, trialLogs];
Counts[convergeStats]
findConvergeEpochNum[aTrial_] := Min[
    Select[
        Table[i, {i, 1, 100}], 
        aTrial[[#]]["errors"] == 0 &
    ]
];

convergeEpochStats = Counts[Map[findConvergeEpochNum, trialLogs]];
convergeEpochStats

experiments = EmpiricalDistribution[
    Values[convergeEpochStats] -> Keys[convergeEpochStats]
]

DiscretePlot[CDF[experiments, x], {x, 0, 100}]
DiscretePlot[PDF[experiments, x], {x, 0, 100}]

部分输出结果见下图

在有些参数下模型成功收敛了

在有些参数下模型成功收敛了

错误曲线降到0表示模型成功收敛(正确分类了所有的输入).

最早收敛迭代序数分布

最早收敛迭代序数分布

横轴表示从第几次开始模型收敛,第一幅图是累积概率函数,第二幅图是概率密度函.看起来像是有点接近均匀分布的不规则分布.

我们做的实验是,用$[0, 1]$范围内均匀分布的整数来生成模型的初始参数,训练集,测试集都是

{{0, 0}, {0, 1}, {1, 1}, {1, 0}}

标签集是

{0, 1, 0, 1}

我们随机生成100组初始参数,从这100组初始参数开始训练100个模型,结果发现,大概只有35/100的模型成功收敛,结论就是要多找几组随机初始参数,否则模型极有可能不收敛,至少在训练用于拟合$\text{XOR}$函数的多层感知机时是这样.

神经网络

回顾我们刚才学习的多层感知机,在一个多层感知机中,每一个非输入层的神经元所做的事情其实都是相似的,无非是:1)对所有收到的来自上一层神经元的数值进行加权求和;2)加权求和后,得到一个值,对这个值进行某种处理.这里说的某种处理*,在多层感知机中,实际上就是用一个符号函数$\text{sign}$来处理,如果输入值大于等于$0$,$\text{sign}$函数返回$1$,否则返回$0.不禁想,这个$\text{sign}$函数可否被替换呢?也就是说,我们用找到的另外一个函数$f$,把每个神经元的加权求和值$s$输入到这个$f$得到$f(s)$,再把$f(s)$而不是$\text{sign}(s)$作为这个神经元的输出,经过这样处理后的「多层感知机」是不是也能够实现分类功能呢?

答案其实是肯定的,在神经网络中,$\text{sign}$这样的用来计算每一个非输入层神经元输出值的函数被叫做「激活函数」,在神经网络中,人们会采用多种「激活函数」,而不局限于$\text{sign}$,很多函数都可以被用来当做激活函数,常用的有$\mathrm{Sigmoid}$函数,$\mathrm{TanH}$函数,$\mathrm{ReLU}$函数.由于采用了不同的激活函数,神经网络各有特色,有的由不同的训练方式(寻找参数的方式),有的擅长不同类型的数据集和不同领域的任务,有的效果很好,有的效果一般……

在神经网络中,我们可以就不用$\mathrm{sign}$函数作为每个神经元的激活函数了,事实上,在接下来的例子中,我打算用$\mathrm{Sigmoid}$函数作为每个非输入层神经元的激活函数

$$ \mathrm{Sigmoid}(x) = \frac{1}{1 + e^{-x}} $$

这个函数的一大特性就是

$$ \frac{\mathsf{d}}{\mathsf{d} x} \mathrm{Sigmoid}(x) = (1 - \mathrm{Sigmoid}(x)) \mathrm{Sigmoid}(x) $$

有了这个特性,在求经验误差函数对参数的导数时会非常方便,首先,经验误差函数可以写成

$$ E = \sum_{\mathbf{x} \in D} (f(\mathbf{x}, \mathbf{\omega}) - t(\mathbf{x}))^2 $$

对某个参数$\omega_{i,j}^{(n)}$求导后就变成

$$ \frac{\partial}{\partial \omega_{i,j}^{(n)}} E = \sum_{\mathbf{x} \in D} 2 (f(\mathbf{x}, \mathbf{\omega}) - t(\mathbf{x})) \frac{\partial}{\partial \omega_{i,j}^{(n)}} f(\mathbf{x}, \mathbf{\omega}) $$

终归还是涉及到对$f$求导,而这里$f$都是$\mathrm{Sigmoid}$复合得到的,所以实际求导起来也是非常好.回想起来,在学习多层感知机时,当我们求$\mathrm{sign}$的复合函数对$\omega_{i,j}^{(n)}$的导数的时候,还要人为地把$\mathrm{sign}(\omega \mathbf{x})$看做$\omega \mathbf{x}$,非常不自然,而在这里,$\mathrm{Sigmoid}$显然不像$\mathrm{sign}$,所以我们不必再那样.$\mathrm{Sigmoid}$就是$\mathrm{Sigmoid}$,不必再把它看成什么,所以我们可以直接用符号计算软件Mathematica程序化地自动地计算神经网络的预测函数$f$对每一个参数的导数,进而得到经验误差函数对每一个参数的导数,进而可以实现梯度下降.

首先,我们还是打算用神经网络来拟合$\mathrm{XOR}$函数,层数是3层,第一层输入层(Input Layer)什么都不做,第二层$\mathrm{Sigmoid}$中间层或者叫做隐含层(Hidden Layer),它的作用是把来自第一层的信号加权求和,分配到这一层的各个神经元,再输入到$\mathrm{Sigmoid}$函数作为输出,第三层是输出层(Output Layer),也是$\mathrm{Sigmoid}$层因为它选用$\mathrm{Sigmoid}$做激活函数,把来自第二层的信号加权平均后输入$\mathrm{Sigmoid}$将得到的值作为输出,实际应用中,我们还会加上一个虚拟的第四层,叫做「阈值层」(Threshold Layer),它接受第三层输出的信号,如果来自第三层的值大于或者等于$0.5$($\mathrm{Sigmoid}$输出的数学意义是概率),那么输出$1$,否则输出$0$,这样才好和真实标签做比较,并且我们最终想要的其实是标签输出而非概率(不过在计算ROC曲线时要用到概率.我们所组建的这个神经网络如下图

用于拟合XOR的神经网络结构图

用于拟合XOR的神经网络结构图

大家可以看到其实就是把原来$\mathrm{sign}$的地方替换成$\mathrm{Sigmoid}$函.然后我们在Mathematica中得到如图所示的神经网络的复合函数表示,首先修改一下layer2Outputlayer3Output,把它们改成$\mathrm{Sigmoid}$函数,在Mathematica中就是LogisticSigmoid

layer1Input[input_] := input;
layer1Output[input_] := Join[{{1}}, input];
layer2Input[input_, weights_] := weights.input;
layer2Output[input_] := Join[{{1}}, LogisticSigmoid[input]];
layer3Input[input_, weights_] := weights.input;
layer3Output[input_] := LogisticSigmoid[input][[1]][[1]];
threshold[input_] := If[input >= 0.5, 1, 0];

然后我们构造我们的神经网络,由于我们编写的函数都是矩阵输入和矩阵输出的,因此它们可以很轻松地被“连接”起来协同工作,就像乐高积木那样可以自由组合,一个叫pseudoPredictor是不包括最后的阈值层(Threshold Layer)的,它是可直接求导的函数,还有一个叫做finalPredictor还额外包含一层判断层如果大于$0.5$输出$1$否则输出$0$,这个不好求导,只是用来得到模型最终的输出(标签$0$或者$1$).

pseudoPredictor[weights_] := Composition[
    layer3Output,
    layer3Input[#, weights[[2]]] &,
    layer2Output,
    layer2Input[#, weights[[1]]] &,
    layer1Output,
    layer1Input
];

finalPredictor[weights_] := Composition[
    threshold,
    layer3Output,
    layer3Input[#, weights[[2]]] &,
    layer2Output,
    layer2Input[#, weights[[1]]] &,
    layer1Output,
    layer1Input
];

下边的tbdInputtbdWeights的tbd表示To Be Decide,即,一个是待定输入一个是待定输出,它们以符号的形式存在,是为了得到一个函数,可以让我们代数进去算

tbdInput = {{x[1]}, {x[2]}};
tbdWeights = {
    {
        {w[1][1][0], w[1][1][1], w[1][1][2]},
        {w[1][2][0], w[1][2][1], w[1][2][2]}
    },
    {
        {w[2][1][0], w[2][1][1], w[2][1][2]}
    }
};

derivatives = D[
    pseudoPredictor[tbdWeights][tbdInput], 
    {Flatten[tbdWeights]}
];

deltaW = eta*derivatives;
deltaWReshaped = {
    ArrayReshape[Take[deltaW, 6], {2, 3}], 
    ArrayReshape[Take[deltaW, {7, 9}], {1, 3}]
};

gradients[weights_, input_, real_] := (
    2 * (finalPredictor[weights][input] - real) * (
        deltaWReshaped /. AssociationThread[
            Flatten[tbdWeights] -> Flatten[weights]
        ] /. AssociationThread[
            Flatten[tbdInput] -> Flatten[input]
        ]
    )
);

allGradients[weights_] := Fold[
    Plus, 
    Table[
        gradients[weights, dataSet[[i]], trueLabels[[i]]], 
        {i, Length[dataSet]}
    ]
];

可以看到pseudoPredictor[tbdWeights][tbdInput]就是我们的神经网络预测函数了,它的地位差不多等同于我们在讨论多层感知机时用到的$f_{\mathrm{XOR}}(\mathbf{x}, \mathbf{\omega})$,其实也就代表神经网络的输出值,allGradients在整个数据集上计算梯度,由于数据量太小,我们就不分测试集和训练集了,我们是为了拟合$\text{XOR}$函数,这个函数的输入空间和输出空间都是确定的,所以我们不需要模型有什么泛化性能,所以,要是拟合的话,简单的把整个数据集既当做训练集又当做测试集是没问题的.

weightsEvolver[modelInfo_, indexes_, trueLabels_] := Module[
    {
        thisWeights,
        thisPredicts,
        thisErrors
    },
    (
        thisWeights = 
        modelInfo["weights"] - eta*allGradients[modelInfo["weights"]];

        thisPredicts = Map[finalPredictor[thisWeights], dataSet];
        thisErrors = Total[(trueLabels - thisPredicts)^2];

        Association[{
            "weights" -> thisWeights,
            "predicts" -> thisPredicts,
            "errors" -> thisErrors
        }]
    )
];

evolver[modelInfo_] := weightsEvolver[
    modelInfo, 
    {1, 2, 3, 4}, 
    {0, 1, 0, 1}
];

函数evolver主要工作就是,根据输入的参数计算梯度,然后根据学习率和梯度得到修正后的梯度,如此反复就是梯度下降,次数足够多后模型会最终收敛.

trialW = randomInitialWeights[];
model1 = Association[{"weights" -> randomInitialWeights[]}];
evolutions = NestList[evolver, model1, 10000];
ListLinePlot[DeleteCases[Map[#["errors"] &, evolutions], _Missing]]

initialWeights = First[evolutions]["weights"]
finalWeights = Last[evolutions]["weights"]
Map[finalPredictor[finalWeights], dataSet]

我们用了随机参数,让梯度下降法迭代了10000次,模型最终在大约地5000次迭代的时候收敛:

在很多很多次迭代之后收敛

在很多很多次迭代之后收敛

这里,最终的参数是

{
    {
        {0.813716, 0.790986, 0.381296}, 
        {1.18453, 1.6255, 0.960664}
    }, 
    {
        {-0.190611, -0.469364, 0.616528}
    }
}

而最初随机生成得到的那个参数是

{
    {
        {1, 1, 0}, 
        {1, 1, 1}
    }, 
    {
        {0, 0, 1}
    }
}

虽然说整个梯度下降过程迭代了6000次之久,但是时间也只大约是1分钟左右,可以看到我们的模型最终还是拟合了数据集

{0, 0} -> 0
{0, 1} -> 1
{1, 1} -> 0
{1, 0} -> 1

并且我们通过编程的方式让软件自动化地计算出预测函数对参数的导数,进而也能让经验误差函数对参数的导数得以以符号的形式表示出来从而也就可以程序化地代值进去算,采用$\mathrm{Sigmoid}$函数了之后,整个神经网络模型和误差逆传播梯度下降算法实现起来变得简单且简洁了好多,不用我们手工地再去一行一行地推导公式了,因为这些工作都可交由计算机软件来完.

总结

小小的感知机能够对线性可分的数据集进行分类,而小小的感知机首尾相接,就得到多层感知机,甚至能够对线性不可分的数据集进行分类,小小的感知机很容易训练,多层感知机引入了误差逆传播算法,需要计算复合函数的导数,多层感知机的激活函数进行替换,就是神经网络,神经网络结构更加自由,不同的激活函数,不同的网络结构,是有差别的,可能是性能上的,可能是训练成本上的,可能是梯度表达式上的,总之是有差别的,但是当我们用$\mathrm{Sigmoid}$函数取代$\mathrm{sign}$函数作为激活函数的时候求梯度一下子变得简洁了,可以由程序来完成,自动化程度大大提高,符号计算的威力初步体现出来,Mathematica就像一个大宝藏可以让我们多次去探.再者,我们还体会到了向量化运算的简便性,神经网络的预测计算过程也被叫做「前馈」,根据预测值和实际值的差别(也就是误差)对参数做出修正也被叫做「后馈」,其中,前馈其实无非就是矩阵相乘,而后馈无非就是复合函数的求导,这样看来,神经网络So Easy!(哈哈,开玩笑)

我们事实上还有很多关于神经网络的知识点没有覆盖到,不过,如果仅限于讲解神经网络比较基础的知识的一部分,应该差不多算是完成.我们希望您阅读更多的资料,观看更多的讲解视频,并自己手动编写程序来验证自己的想法和理解与实际是否有偏.神经网络,就程序代码和算法来看,不同的人会写出不同的算法,具体的代码实现更是千差万别,是没有一种固定标准的,因为神经网络它本身只是一种大概的方法——通过可以由网络表示的复杂的复合函数去拟合一个未知函数,读者不必拘泥于具体的实现.

推荐资料

[1] 数据挖掘:理论与算法-清华大学-学堂在线

评论:从概念上去理解,并自由地尝试去实现它!

[2] Machine Learning | Coursera

评论:数学和形式化思考是求解问题的基本途径!

数据挖掘neuralnetperceptron

介绍我最近开始写的一个有意思的项目

谈一谈复数、欧拉公式和割圆术