*This notebook contains course material from [CBE30338](https://jckantor.github.io/CBE30338)
by Jeffrey Kantor (jeff at nd.edu); the content is available [on Github](https://github.com/jckantor/CBE30338.git).
The text is released under the [CC-BY-NC-ND-4.0 license](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode),
and code is released under the [MIT license](https://opensource.org/licenses/MIT).*

< [Modeling and Control of a Campus Outbreak of Coronavirus COVID-19](http://nbviewer.jupyter.org/github/jckantor/CBE30338/blob/master/notebooks/03.09-COVID-19.ipynb) | [Contents](toc.ipynb) | [Implementing PID Controllers with Python Yield Statement](http://nbviewer.jupyter.org/github/jckantor/CBE30338/blob/master/notebooks/04.01-Implementing_PID_Control_with_Python_Yield_Statement.ipynb) >

# PID Control

Proportional-Integral-Derivative (PID) control is a workhorse of modern engineering. Orginally developed in the 1920's for the automatic steering of ships, the technology eventually found its way to the process industries following publication of _Principles and Practice of Automatic Control_ for the British trade magazine _The Engineer_ in 1937. The publication of this paper kicked off a period of rapid innovation in which instrument makers developed the equipment to put these ideas into practice, and the process industries adopted these new technologies.

In this series of notebooks we will review the basic formulations of PID control, issues that arise and how they are managed, and develop a basic implementation. Our goal here is to provide with the knowledge needed to understand the principles of PID control, how to put the technology to work, and how to recognize and troubleshoot issues arising in practice.

## PID Control Formulations

### Non-interacting Form

A typical mathematical formulation known as the **non-interacting form** of PID control can be written

\begin{align}
MV & = \overline{MV} + K_P \left(SP - PV\right) + K_I\int_0^t (SP - PV)\ dt' + K_D\frac{d(SP - PV)}{dt}
\end{align}

where we use the generic notation $MV$ to denote the manipulated variable, and $PV$ the process variable to be controlled to the desired setpoint value $SP$. $\overline{MV}$ denotes a nominal or reference value of the manipulated variable. The remaining terms on the right hand side are prescription of how to adjust the manipulated variable in response to a deviation of the process variable for the desired setpoint value.

In this formulation there are three coefficients, $K_P$, $K_I$, and $K_D$ denoting the relative contributions of the **proportional**, **integral**, and **derivative** terms to the control action.  These constants can be adjusted to achieve a useful closed-loop dynamics.

### ISA Form

Another commmon formulation (sometimes also called the **ideal**, or **additive* form) is given by

\begin{align}
MV & = \overline{MV} + K_c \left[ \left(SP - PV\right) + \frac{1}{\tau_I}\int_0^t (SP - PV)\ dt' + \tau_D\frac{d(SP - PV)}{dt}
\right]\end{align}

For the purposes of control, this form has the same functionality of the non-interacting form but with a different set of parameters. The constant $K_c$ is called the **control gain**, $\tau_I$ the **integral time constant**, and $\tau_D$ the **derivative time constant**.

Comparing these two forms of PID control shows they are identical under a conversion of parameters. Going from ISA to non-interacting form

\begin{align}
K_P & = K_c \\
K_I & = \frac{K_c}{\tau_I} \\
K_D & = K_c\tau_D
\end{align}

or from non-interacting to ISA form

\begin{align}
K_c & = K_P \\
\tau_I & = \frac{K_P}{K_I} \\
\tau_D & = \frac{K_D}{K_P}
\end{align}

## Implementation

In these notebooks we'll proceed through a series of PID control implementations. These are intended to demonstrate key features of the control algorithm, and issues that arise in practice. Since these implementations are for demonstration only, we'll keep them as simple and straightforward as possible.

```python

```

< [Modeling and Control of a Campus Outbreak of Coronavirus COVID-19](http://nbviewer.jupyter.org/github/jckantor/CBE30338/blob/master/notebooks/03.09-COVID-19.ipynb) | [Contents](toc.ipynb) | [Implementing PID Controllers with Python Yield Statement](http://nbviewer.jupyter.org/github/jckantor/CBE30338/blob/master/notebooks/04.01-Implementing_PID_Control_with_Python_Yield_Statement.ipynb) >


# 第十三讲：最大期望算法及其应用、因子分析模型

上一讲我们介绍了无监督学习算法，现在使用的训练集都是没有类标记的数据集$\left\{x^{(1)},\cdots,x^{(m)}\right\}$。我们想要对样本进行分类并得到每个类型的样本的的密度分布。于是，我们引入潜在变量$z$，用它来表示样本所属的分布，并将数据建模为$\displaystyle P(x)=\sum_zP(x\mid z)P(z)$。在前半节课的介绍中，我们假设数据服从不同的高斯分布，并使用高斯混合模型来对样本建模，也就是令$z\sim\mathrm{Multinomial}(\phi)$，令$x\mid z=j\sim\mathcal N\left(\mu_j,\varSigma_j\right)$，使用EM算法的步骤迭代求出参数。

后半节课介绍了泛化的EM算法，该算法的目标函数为$\displaystyle\operatorname*{max}_\theta\sum_i\log p\left(x^{(i)};\theta\right)$，因为潜在变量的存在，目标函数又可以表示为$\displaystyle\max_\theta\sum_i\log\sum_z^{(i)}\log p\left(x^{(i)},z^{(i)};\theta\right)$。接下来就是使用E步骤和M步骤迭代求参数了：

* E步骤：$Q_i\left(z^{(i)}\right):=p\left(z^{(i)}\mid x^{(i)};\theta\right)$，构建目标函数$\mathscr l$的下界；
* M步骤：$\displaystyle\theta:=\arg\max_\theta\underbrace{\sum_i\sum_{z^{(i)}}Q_i\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta\right)}{Q_i\left(z^{(i)}\right)}}_\textrm{lower-bound function}$，最大化这个下界函数，求得新的参数。（如果把右边这个下界看做关于$\theta$的函数，那么这个下界就是$\mathscr l$的下界函数。）

再介绍一下我们为什么要使用EM算法：因为直接最大化$\mathscr l$，对参数求偏导、再将偏导置为零求参数的计算过程，会由于潜在变量的存在而难以求得解析解；而EM算法M步骤中的最大化的式子通常都是很容易由偏导解出解析解的形式。

接着上一讲继续介绍EM算法。

怎么能确定算法一定收敛呢？设$\theta^{(t)}$和$\theta^{(t+1)}$是EM算法两次成功迭代得到的参数，只要证明$\mathscr l\left(\theta^{(t)}\right)\leq\mathscr l\left(\theta^{(t+1)}\right)$，就可以说明EM算法会随着迭代使目标函数值单调增加。而证明的关键在于$Q_i$的选择。当EM算法从参数$\theta^{(t)}$开始迭代时，我们会选择$Q_i^{(t)}\left(z^{(i)}\right):=p\left(z^{(i)}\mid x^{(i)};\theta^{(t)}\right)$，在上一讲最后，我们看到这个选择保证了延森不等式成立，并且使[上一讲](chapter12.ipynb)中的$(3)$式取等号，而因为$\displaystyle\mathscr l\left(\theta^{(t)}\right)=\sum_i\sum_{z^{(i)}}Q_i^{(t)}\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta^{(t)}\right)}{Q_i^{(t)}\left(z^{(i)}\right)}$，则最大化等号右边的部分就可以得到参数$\theta^{(t+1)}$。因此：

$$
\begin{align}
\mathscr l\left(\theta^{(t+1)}\right)&\geq\sum_i\sum_{z^{(i)}}Q_i^{(t)}\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta^{(t+1)}\right)}{Q_i^{(t)}\left(z^{(i)}\right)}\tag{4}\\
&\geq\sum_i\sum_{z^{(i)}}Q_i^{(t)}\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta^{(t)}\right)}{Q_i^{(t)}\left(z^{(i)}\right)}\tag{5}\\
&=\mathscr l\left(\theta^{(t)}\right)\tag{6}
\end{align}
$$

* $(4)$式来自$\displaystyle\mathscr l(\theta)\geq\sum_i\sum_{z^{(i)}}Q_i\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta\right)}{Q_i\left(z^{(i)}\right)}$，不等式对任意$Q_i$和$\theta$均成立，而对于$Q_i=Q_i^{(t)},\theta=\theta^{(t+1)}$时，则可以取到等号。
* 而要得到$(5)$式，需要利用$\theta^{(t+1)}$的定义式：$\displaystyle\mathrm{arg}\operatorname*{max}_\theta\sum_i\sum_{z^{(i)}}Q_i\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta\right)}{Q_i\left(z^{(i)}\right)}$，正是这个式子使以$\theta^{(t+1)}$为参数的目标函数总是等于或大于以$\theta^{(t)}$为参数的目标函数。
* $(6)$式已经在前面出现过，为了在参数为$\theta^{(t)}$时，能够使延森不等式取到等号而选择的$Q_i^{(t)}$。

因此，EM算法能够使似然函数单调收敛。在EM算法的描述中，我们提到“运行直到收敛”，依照上面的推导，一个合理的收敛检测可以是：检查两次成功迭代后$\mathscr l(\theta)$的增量，如果增量小于某个阈值，我们就可以说“因为EM算法增长已然很慢了，所以算法已经收敛”。

**注意：**如果我们定义$\displaystyle J(Q,\theta)=\sum_i\sum_{z^{(i)}}Q_i\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta\right)}{Q_i\left(z^{(i)}\right)}$，那么从前面的推导中可以得出$\mathscr l(\theta)\leq J(Q,\theta)$，则EM算法可以看做是函数J上的坐标上升过程，而E步骤就求$J$关于$Q$的函数的最大值，而M步骤就是求$J$关于$\theta$的函数的最大值。

## 3. 再看高斯混合模型

有了一般化定义的EM算法，我们重新审视上一讲中的高斯混合模型，拟合模型中的参数$\phi,\mu,\varSigma$。为了简洁一些，我们只看M步骤中关于$\phi,\mu_j$的参数更新过程，将$\varSigma_j$的更新留给读者考虑。

E步骤很简单，根据上面的推导，有：

$$
w_j^{(i)}=Q_i\left(z^{(i)}=j\right)=P\left(z^{(i)}=j\mid x^{(i)};\phi,\mu,\varSigma\right)=\frac{p\left(x^{(i)}\mid z^{(i)}=j;\mu,\varSigma\right)p\left(z^{(i)}=j;\phi\right)}{\sum_{i=1}^kp\left(x^{(i)}\mid z^{(i)}=l;\mu,\varSigma\right)p\left(z^{(i)}=l;\phi\right)}
$$

这里的$Q_i\left(z^{(i)}=j\right)$表示在$Q_i$分布中$z^{(i)}$取$j$的概率。

接下来是M步骤，我们需要最大化关于参数$\phi,\mu,\varSigma$的下界函数：

$$
\begin{align}
\sum_{i=1}^m\sum_{z^{(i)}}Q_i\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\phi,\mu,\varSigma\right)}{Q_i\left(z^{(i)}\right)}&=\sum_{i=1}^m\sum_{j=1}^kQ_i\left(z^{(i)}=j\right)\log\frac{p\left(x^{(i)}\mid z^{(i)}=j;\mu,\varSigma\right)p\left(z^{(i)}=j;\phi\right)}{Q_i\left(z^{(i)}=j\right)}\\
&=\sum_{i=1}^m\sum_{j=1}^kw_j^{(i)}\log\frac{\frac{1}{\sqrt{(2\pi)^n\left\lvert\varSigma_j\right\rvert}}\exp\left(-\frac{1}{2}\left(x^{(i)}-\mu_j\right)^T\varSigma_j^{-1}\left(x^{(i)}-\mu_j\right)\right)\cdot\phi_j}{w_j^{(i)}}
\end{align}
$$

首先以$\mu_l$为变量做最大化，如果我们对$\mu_l$求偏导，则有：

$$
\begin{align}
\nabla_{\mu_l}\sum_{i=1}^m\sum_{j=1}^kw_j^{(i)}\log\frac{\frac{1}{\sqrt{(2\pi)^n\left\lvert\varSigma_j\right\rvert}}\exp\left(-\frac{1}{2}\left(x^{(i)}-\mu_j\right)^T\varSigma_j^{-1}\left(x^{(i)}-\mu_j\right)\right)\cdot\phi_j}{w_j^{(i)}}&=-\nabla_{\mu_l}\sum_{i=1}^m\sum_{j=1}^kw_j^{(i)}\frac{1}{2}\left(x^{(i)}-\mu_j\right)^T\varSigma_j^{-1}\left(x^{(i)}-\mu_j\right)\\
&=\frac{1}{2}\sum_{i=1}^mw_l^{(i)}\nabla_{\mu_l}2\mu_l^T\varSigma_l^{-1}x^{(i)}-\mu_l^T\varSigma_l^{-1}\mu_l\\
&=\sum_{i=1}^mw_l^{(i)}\left(\varSigma_l^{-1}x^{(i)}-\varSigma_l^{-1}\mu_l\right)
\end{align}
$$

将式子置为零再解出$\mu_l$，就得到了参数$\mu$的更新规则：

$$
\mu_l:=\frac{\sum_{i=1}^mw_l^{(i)}x^{(i)}}{\sum_{i=1}^mw^{(i)}}
$$

这就是上一讲中参数$\mu$更新规则的由来。

再来推导一个M步骤中的参数$\phi$的更新规则。要对$\phi$求偏导，则将原式中含有$\phi$的项提出来，现在需要最大化：$\displaystyle\sum_{i=1}^m\sum_{j=1}^kw_j^{(i)}\log\phi_j$。但是要注意，因为$\phi_j=p\left(z^{(i)}=j;\phi\right)$是一个概率表达式，所以参数还有一个限制条件——$\phi_j$之和为$1$。为了处理限制条件$\displaystyle\sum_{j=1}^k\phi_j=1$，我们构造拉格朗日算子：

$$
\mathcal L(\phi)=\sum_{i=1}^m\sum_{j=1}^kw_j^{(i)}\log\phi_j+\beta\left(\sum_{j=1}^k\phi_j-1\right)
$$

式中的$\beta$是拉格朗日乘数（我们不需要担心$\phi_j\geq0$的限制条件，在后面会看到，从这里推导出的结论将恰好能够保证该条件成立）。求偏导得：

$$
\frac{\partial}{\partial\phi_j}\mathcal L(\phi)=\sum_{i=1}^m\frac{w_j^{(i)}}{\phi_j}+\beta
$$

将偏导置为零得到$\displaystyle\phi_j=\frac{\sum_{i=1}^mw_j^{(i)}}{-\beta}$，也就是说$\phi_j\propto\sum_{i=1}^mw_j^{(i)}$。由于$\displaystyle\sum_j\phi_j=1=\sum_{j=1}^k\frac{\sum_{i=1}^mw_j^{(i)}}{-\beta}=\frac{\sum_{i=1}^m\sum_{j=1}^kw_j^{(i)}}{-\beta}=\frac{m}{-\beta}\implies-\beta=m$（因为$w_j^{(i)}=Q_i\left(z^{(i)}=j\right)$是一个概率密度，所以有$\sum_jw_j^{(i)}=1$）。于是我们就得到了参数$\phi$的更新规则：

$$
\phi_j:=\frac{1}{m}\sum_{i=1}^mw_j^{(i)}
$$

## 4. 再看混合朴素贝叶斯模型

简要介绍一下前面的（[第五讲](chapter5.ipynb)）朴素贝叶斯模型在EM算法下的应用。

* 给定的训练集为$\left\{x^{(1)},\cdots,x^{(m)}\right\},x^{(i)}\in\{0,1\}^n$，即每个样本都是一个$n$维位向量，比如$x_j^{(i)}=1\{\textrm{单词}j\textrm{是否出现在文档中}\}$；
* 模型中有潜在变量$z^{(i)}\in\{0,1\}$（也就是说在本例中我们希望找到两个簇，而如果希望有更多分类的话可以继续扩展），$z^{(i)}\sim\mathrm{Bernoulli}(\phi)$；
* 同前面讲过的朴素贝叶斯模型一样，有$\displaystyle p\left(x^{(i)}\mid z^{(i)}\right)=\prod_{j=1}^np\left(x_j^{(i)}\mid z^{(i)}\right)$，特别的有$p\left(x_j^{(i)}=1\mid z^{(i)}=0\right)=\phi_{j\mid z=0}$；

如果把上面条件中的$z$换成$y$，就和前面介绍的朴素贝叶斯模型一样了。有了这些条件，应用EM算法：

* E步骤：$w^{(i)}:=p\left(z^{(i)}=1\mid x^{(i)};\phi_{j\mid z},\phi\right)$；
* M步骤：

$$
\begin{align}
\phi_{j\mid z=1}&=\frac{\sum_{i=1}^mw^{(i)}1\left\{x_j^{(i)}=1\right\}}{\sum_{i=1}^mw^{(i)}}\\
\phi_{j\mid z=0}&=\frac{\sum_{i=1}^m\left(1-w^{(i)}\right)1\left\{x_j^{(i)}=1\right\}}{\sum_{i=1}^mw^{(i)}}\\
\phi_z&=\frac{\sum_{i=1}^mw^{(i)}}{m}
\end{align}
$$

# 第十部分：因子分析（Factor analysis）

当我们使用高斯混合模型对数据集$x^{(i)}\in\mathbb R^n$建模时，可以使用EM算法拟合模型求出参数。在这种情形下，我们通常都有足够的样本，可以用多个高斯分布将它们分开。这也就是训练样本数量$m$远大于数据维数$n$的情形。

现在，来考虑另一种情形——当$n\gg m$时。在这种情形下，因为样本太少，即使只用一个高斯分布也难以对数据进行建模，更别说用多个高斯分布将数据分开了。特别是因为样本短缺，仅$m$个样本在$\mathbb R^n$空间中只能张成一个维度很低的子空间，如果此时使用高斯分布建模，并使用最大似然估计对期望和协方差做出估计时：

$$
\begin{align}
\mu&=\frac{1}{m}\sum_{i=1}^mx^{(i)}\\
\varSigma&=\frac{1}{m}\sum_{i=1}^m\left(x^{(i)}-\mu\right)\left(x^{(i)}-\mu\right)^T
\end{align}
$$

就会发现，$\varSigma$是一个奇异矩阵，也就是说其逆$\varSigma^{-1}$不存在，则$\displaystyle\frac{1}{\sqrt{\left\lvert\varSigma\right\rvert}}=\frac{1}{0}$，而这几个量在我们计算多元高斯分布的概率密度时都需要使用。换一种方式描述这种困境，我们需要用某个高斯分布拟合数据集，而这个高斯分布的参数的最大似然估计的所有可能性将会落在一个由样本数据张成的仿射空间中（对某些满足$\sum_{i=1}^m\alpha_i=1$的$\alpha_i$，能够使数据集中的样本$x$满足$x=\sum_{i=1}^m\alpha_ix^{(i)}$），这也意味着其协方差矩阵是奇异的。

更普遍的说，如果样本数量$m$没有比特征数量$n$大出合理的倍数，则得到的期望和协方差参数都会很差。尽管如此，我们依然希望使用一个合理的高斯模型拟合数据集，也许能够得到一些有趣的关于数据的协方差矩阵，那么该怎么做呢？

在下一节，我们会介绍两种加在$\varSigma$上的约束，以保证它可逆。其中一种允许我们用很小的数据集拟合$\varSigma$，不过这两种方法都不能很好的解决我们的问题；然后讨论一下某些会在在后面用到的高斯分布的性质，特别是如何求高斯分布的边缘分布及条件分布；最后将介绍因子分析模型，并对它应用EM算法。

## 1. $\varSigma$的约束条件

如果没有足够的样本来拟合一个完整的协方差矩阵，我们就可以考虑在其矩阵空间上加一些约束。比如说，我们可以选择拟合一个对角矩阵形式的$\varSigma$。在这种情形下，容易验证对角矩阵$\varSigma$的最大似然估计满足$\displaystyle\varSigma_{jj}=\frac{1}{m}\sum_{i=1}^m\left(x_j^{(i)}-\mu_j\right)^2$，其中$\varSigma_{jj}$就是样本数据第$j$个分量的方差。

回忆前面（[第五讲](chapter05.ipynb)）介绍的高斯分布概率密度的等高线图，普通的$\varSigma$对应的等高线图的主轴大都是倾斜的，而当$\varSigma$是一个对角矩阵时，其等高线图的主轴是平行于坐标轴的。

有时，我们会加上更严格的约束条件，除了要令$\varSigma$是对角矩阵外，还要令其对角线元素是相等的。在这种情形下，我们令$\varSigma=\sigma^2I$，其中的$\sigma^2$是需要估计的参数：$\displaystyle\sigma^2=\frac{1}{mn}\sum_{j=1}^n\sum_{i=1}^m\left(x_j^{(i)}-\mu_j\right)^2$。在这种情形下的高斯分布概率密度的等高线图将是正圆形（在二维情况下，而非普通$\varSigma$下的椭圆；在高维情况下则是球面或超球面）。

如果我们要用数据集拟合一个完整的、不加约束条件的协方差矩阵$\varSigma$，则至少要满足$m\geq n+1$，以保证$\varSigma$的最大似然估计是非奇异矩阵。在上面的两种情形下，只要$m\geq2$，我们得到的$\varSigma$就是非奇异的。

不过，将$\varSigma$限制为对角矩阵也就意味着样本的任意两个分量$x_i,x_j$之间是无关且独立的。通常，在不加约束条件时，通过数据拟合得到的$\varSigma$将捕获到一些关于数据的有趣的潜在联系，而我们使用上面任意一种约束后，就相当于强行抹掉了样本各分量之间的联系。在后面对因子分析模型的介绍中，我们将在对角矩阵约束之外添加更多参数，用以捕获数据间的联系，但同样不会拟合一个完整的协方差矩阵。

## 2. 高斯分布的边缘分布及条件分布

在介绍因子分析模型之前，我们先了解一下计算多元高斯分布下随机变量的边缘分布及条件分布的计算。

* 设有向量形式的随机变量$x=\begin{bmatrix}x_1\\x_2\end{bmatrix},\ x_1\in\mathbb R^r,x_2\in\mathbb R^s,x\in\mathbb R^{r+s}$；
* 设$x\sim\mathcal N\left(\mu,\varSigma\right)$，其中$\mu=\begin{bmatrix}\mu_1\\\mu_2\end{bmatrix},\ \varSigma=\begin{bmatrix}\varSigma_{11}&\varSigma_{12}\\\varSigma_{21}&\varSigma_{22}\end{bmatrix},\ \mu_1\in\mathbb R^r,\mu_2\in\mathbb R^s,\varSigma_{11}\in\mathbb R^{r\times r},\varSigma_{12}\in\mathbb R^{r\times s},\varSigma_{21}\in\mathbb R^{s\times r},\varSigma_{22}\in\mathbb R^{s\times s}$，而且协方差矩阵是对称的，所以有$\varSigma_{21}=\varSigma_{12}^T$。

在上面的假设下，$x_1,x_2$就是多元高斯分布。如何计算$x_1$的边缘分布？不难发现$\mathrm E[x_1]=\mu_1$，$\mathrm{Cov}\left(x_1\right)=\mathrm E\left[\left(x_1-\mu_1\right)\left(x_1-\mu_1\right)^T\right]=\varSigma_{11}$。可以亲自验证$x_1$的协方差，使用$x_1,x_2$间的协方差定义式：

$$
\begin{align}
\mathrm{Cov}&=\varSigma\\
&=\begin{bmatrix}\varSigma_{11}&\varSigma_{12}\\\varSigma_{21}&\varSigma_{22}\end{bmatrix}\\
&=\mathrm E\left[\left(x-\mu\right)\left(x-\mu\right)^T\right]\\
&=\mathrm E\begin{bmatrix}\begin{pmatrix}x_1-\mu_1\\x_2-\mu_2\end{pmatrix}\begin{pmatrix}x_1-\mu_1\\x_2-\mu_2\end{pmatrix}^T\end{bmatrix}\\
&=\mathrm E\begin{bmatrix}\left(x_1-\mu_1\right)\left(x_1-\mu_1\right)^T&\left(x_1-\mu_1\right)\left(x_2-\mu_2\right)^T\\\left(x_2-\mu_2\right)\left(x_1-\mu_1\right)^T&\left(x_2-\mu_2\right)\left(x_2-\mu_2\right)^T\end{bmatrix}
\end{align}
$$

多元高斯分布的边缘分布也是高斯分布，则随机变量$x_1$的边缘分布为$x_1\sim\mathcal N\left(\mu_1,\varSigma_{11}\right)$。

那么，在给定$x_2$的条件下$x_1$的条件分布又该怎么计算？根据多元高斯分布的定义有$x_1\mid x_2\sim\mathcal N\left(\mu_{1\mid2},\varSigma_{1\mid2}\right)$，其中：

$$
\begin{align}
\mu_{1\mid2}&=\mu_1+\varSigma_{12}\varSigma_{22}^{-2}\left(x_2-\mu_2\right)\tag{1}\\
\varSigma_{1\mid2}&=\varSigma_{11}-\varSigma_{12}\varSigma_{22}^{-1}\varSigma_{21}\tag{2}
\end{align}
$$

在求解因子分析模型时，需要经常使用这些式子计算边缘分布和条件分布（参见[多元正态分布的条件分布](https://en.wikipedia.org/wiki/Multivariate_normal_distribution#Conditional_distributions)）。

## 3. 因子分析模型

在因子分析模型中，我们假设随机变量$(x,z)$上的联合分布如下（其中$z\in\mathbb R^k$是潜在随机变量）：

$$
\begin{align}
z&\sim\mathcal N(0,I)\\
x\mid z&\sim\mathcal N(\mu+\varLambda z,\varPsi)
\end{align}
$$

模型的参数为：
* $\mu\in\mathbb R^n$；
* 矩阵$\varLambda\in\mathbb R^{n\times k}$；
* $\varPsi\in\mathbb R^{n\times n}$，并且是一个对角矩阵。

而通常情况下，我们选择会选择比$n$小的数作为$k$的值。

因此，我们设想数据$x^{(i)}$来自源于$k$维多元高斯分布$z^{(i)}$的一次抽样；然后，通过$\mu+\varLambda z^{(i)}$式将上一步的抽样映射到$\mathbb R^n$的一个仿射空间中；最后，通过向$\mu+\varLambda z^{(i)}$添加协方差噪音$\varPsi$，最终得到$x^{(i)}$。

等价的，我们也可以如下定义因子分析模型模型：

$$
\begin{align}
z&\sim\mathcal N(0,I)\\
\epsilon&\sim\mathcal N(0,\varPsi)\\
x&=\mu+\varLambda z+\epsilon
\end{align}
$$

其中$z,\epsilon$是相互独立的，$\epsilon$同样是噪音项。

现在我们来看看我们的模型究竟定义了一个怎样分布。我们的随机变量$z$和$x$服从联合高斯分布$\begin{bmatrix}z\\x\end{bmatrix}\sim\mathcal N\left(\mu_{zx},\varSigma\right)$，现在需要知道$\mu_{zx}$和$\varSigma$。

我们从$z\sim\mathrm N(0,I)$知道$\mathrm E[z]=0$，于是可以算出：

$$
\begin{align}
\mathrm E[x]&=\mathrm E[\mu+\varLambda z+\epsilon]\\
&=\mu+\varLambda\mathrm E[z]+\mathrm E[\epsilon]\\
&=\mu
\end{align}
$$

综合上面的两个期望能够得到$\mu_{zx}=\begin{bmatrix}\vec0\\\mu\end{bmatrix}$。

接下来要找到$\varSigma$需要计算（协方差矩阵是对称矩阵）：
* $\varSigma_{zz}=\mathrm E\left[(z-\mathrm E[z])(z-\mathrm E[z])^T\right]$（参考上一节协方差推导结果$\varSigma$左上角元素）；
* $\varSigma_{zx}=\mathrm E\left[(z-\mathrm E[z])(x-\mathrm E[x])^T\right]$（参考上一节协方差推导结果$\varSigma$右上角元素）；
* $\varSigma_{xx}=\mathrm E\left[(x-\mathrm E[x])(x-\mathrm E[x])^T\right]$（参考上一节协方差推导结果$\varSigma$右下角元素）。

不过由于$z\sim\mathcal N(0,I)$，我们可以化简$\varSigma=\mathrm{Cov}(z)=I$，也可以化简$\varSigma_{zx}$：

$$
\begin{align}
\mathrm E\left[(z-\mathrm E[z])(x-\mathrm E[x])^T\right]&=\mathrm E\left[z(\mu+\varLambda z+\epsilon-\mu)^T\right]\\
&=\mathrm E\left[zz^T\right]\varLambda^T+\mathrm E\left[z\epsilon^T\right]\\
&=\varLambda^T
\end{align}
$$

关于上面的最后一步推导，一是根据[协方差定义](https://en.wikipedia.org/wiki/Covariance#Definition)有$\mathrm{Cov}(zz^T)=\mathrm E\left[zz^T\right]-\mathrm E[z]\mathrm E\left[z^T\right]=\mathrm E\left[zz^T\right]$（因为$z$的期望为零）；二是根据$z$与$\epsilon$相互独立有$\mathrm E\left[z\epsilon^T\right]=\mathrm E[z]\mathrm E\left[\epsilon^T\right]$（仍是从协方差定义能够得到，独立事件协方差为零，所以独立事件之积的期望等于各自期望之积）。同样也可以化简$\varSigma_{xx}$：

$$
\begin{align}
\mathrm E\left[(x-\mathrm E[x])(z-\mathrm E[x])^T\right]&=\mathrm E\left[(\mu+\varLambda z+\epsilon-\mu)(\mu+\varLambda z+\epsilon-\mu)^T\right]\\
&=\mathrm E\left[\varLambda zz^T\varLambda^T+\epsilon z^T\varLambda^T+\varLambda z\epsilon^T+\epsilon\epsilon^T\right]\\
&=\varLambda\mathrm E\left[zz^T\right]\varLambda^T+\mathrm E\left[\epsilon\epsilon^T\right]\\
&=\varLambda\varLambda^T+\varPsi
\end{align}
$$

综合上面所求的期望和协方差：

$$
\begin{bmatrix}z\\x\end{bmatrix}\sim\mathcal N\left(\begin{bmatrix}\vec0\\\mu\end{bmatrix},\begin{bmatrix}I&\varLambda^T\\\varLambda&\varLambda\varLambda^T+\varPsi\end{bmatrix}\right)\tag{3}
$$

从这个式子也可以看出$x$的边缘分布为$x\sim\mathcal N\left(\mu,\varLambda\varLambda^T+\varPsi\right)$。因此，对于给定的训练集$\left\{x^{(i)};i=1,\cdots,m\right\}$，我们可以得到参数的对数似然函数：

$$
\mathscr l(\mu,\varLambda,\varPsi)=\log\prod_{i=1}^m\frac{1}{\sqrt{(2\pi)^n\left\lvert\varLambda\varLambda^T+\varPsi\right\rvert}}\exp\left(-\frac{1}{2}\left(x^{(i)}-\mu\right)^T\left(\varLambda\varLambda^T+\varPsi\right)^{-1}\left(x^{(i)}-\mu\right)\right)
$$

下一步就是计算最大似然估计，求似然函数关于每个参数的最大值。但是最大化这个式子非常难，我们无法直接计算其最大值的解析解。于是，我们转而使用EM算法：

* E步骤：$Q_i\left(z^{(i)}\right):=p\left(z^{(i)}\mid x^{(i)};\theta\right)$
* M步骤：$\displaystyle\theta:=\mathrm{arg}\operatorname*{max}_\theta\sum_i\int_{z^{(i)}}Q_i\left(z^{(i)}\right)\log\frac{p\left(x^{(i)},z^{(i)};\theta\right)}{Q_i\left(z^{(i)}\right)}\mathrm dz^{(i)}$（因为这里的$z^{(i)}$是一个连续变量，所以使用积分）。

下一节将详细介绍EM算法在因子分析模型上的应用。


```python
import kwant
import sympy
```

```python
%time

from semicon.models import foreman
ham = foreman('xyz')
```

    CPU times: user 2 µs, sys: 0 ns, total: 2 µs
    Wall time: 3.81 µs

```python
%%time
from semicon.kp_models.explicit_foreman import foreman as reference_foreman

varied_parameters = ['E_0', 'E_v', 'Delta_0', 'P', 'kappa', 'g',
                 'gamma_0', 'gamma_1', 'gamma_2', 'gamma_3']

substitutions = {v: v+'(x, y, z)' for v in varied_parameters}
substitutions = {sympy.Symbol(k, commutative=False): kwant.continuum.sympify(v)
                 for k, v in substitutions.items()}
reference = reference_foreman.subs(substitutions)
```

    CPU times: user 1.39 s, sys: 2.4 ms, total: 1.39 s
    Wall time: 1.4 s


```python
# Notebook imports, do not care about them!
import jdc
```

# Introdução

Redes Neurais de Múltiplas Camadas (RNM) são modelos computacionais inspirados no sistema nervoso humano, compostas por estruturas matemáticas que simulam os neurônios e suas conexões. Os usos mais comuns desses modelos consistem em tarefas de classificação e regressão,
o que favorece a aplicação em diversas áreas de pesquisa, sendo uma delas
a de Visão Computacional.

O objetivo deste *notebook* é apresentar os fundamentos das RNM *feedforward fully connected*, também conhecidas como Multilayer Perceptron (MLP), dos pontos de vista matemático e algorítmico, gerando uma implementação
básica de uma MLP em Python. O tratamento matemático foi baseado no excelente livro de Michael Nielsen [1] sobre este tema. As implementações foram adaptadas do também excelente livro de Adrian Rosebrock [2], porém com severas alterações para que houvesse clara correspondência entre a notação matemática e o código. Certamente, a implementação não foi otimizada como poderia (e como o é em bibliotecas especializadas), por propósitos didáticos.

Para compreender as RNM, é importante primeiramente conhecer os seus dois principais componentes: os **neurônios** e a **arquitetura em camadas**. Depois, trataremos sobre o aprendizado utilizando o método do *Gradiente Descendente Estocástico* com o algoritmo *Backpropagation*.

## Neurônios
Um neurônio, no contexto das redes neurais, é uma estrutura que aceita como argumento um vetor $\mathbf{x} = \langle x_1, x_2, \ldots, x_n \rangle \in \mathbb{R}^n$
de entrada e **responde com um valor real** de saída, comumente chamado de **valor de ativação**. Cada neurônio está relacionado a um valor $b \in \mathbb{R}$, chamado **bias**, e a um vetor de pesos $\mathbf{w} = \langle w_1, w_2, \ldots, w_n \rangle \in \mathbb{R}^n$, de forma que cada componente $x_i$ da entrada é associada ao peso $w_i$. Ao receber a entrada,
o neurônio responde com o valor $f(\mathbf{x}\cdot\mathbf{w} + b)$, onde $f$ é chamada de
**função de ativação** e $\mathbf{x}\cdot\mathbf{w} = \sum_i x_i \cdot w_i$, ou seja,
o produto interno entre $\mathbf{x}$ e $\mathbf{w}$, também chamado de $net$ do neurônio. A imagem abaixo
exibe a representação gráfica de um neurônio:

A contante $b$, o *bias*, existe para permitir um ajuste fino
do valor de saída por meio de um deslocamento horizontal da função de ativação. Por essa afirmação,
é de se esperar que $b$ seja ajustado na preparação do modelo, o que de fato acontece:
o *bias* é aprendido pelas redes neurais, juntamente com os pesos.

Uma função comum de ativação é a **sigmoide**, expressa pela equação:
$$f(x) = \frac{1}{1 + e^{-x}}.$$

Ela possui a importante propriedade de ser contínua e derivável em $(-\infty, +\infty)$,
o que favorece o algoritmo de treinamento a ser em breve explicado, apesar de existirem algumas desvantagens em sua utilização. A implementação da nossa MLP utilizará a sigmoide para todas as ativações.

Abaixo, estão os gráficos sem e com *bias* da função sigmoide. Perceba o deslocamento horizontal causado
pelo *bias*:

```python
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

# The sigmoid function
def f(X):
    return 1/(1+np.exp(-X))

# Values for the X axis
X = np.arange(-10,10,.1)
# Bias value
b = -2.5

# Plotting without bias
plt.subplot(211)
plt.plot(X,f(X))
plt.title("Unbiased")
plt.grid(True)

# Plotting with bias
plt.subplot(212)
plt.plot(X,f(X+b))
plt.title("Biased")
plt.grid(True)

plt.subplots_adjust(top=0.92, bottom=0.08, left=0.10, right=0.95, hspace=0.5,
                    wspace=0.35)
plt.show()
```

## Arquitetura em camadas

Uma RNM é organizada em uma sequência de $C > 2$ camadas de neurônios, denotadas aqui por $L_0, L_1, \ldots, L_{C-1}$. O tamanho de uma camada $L_i$, denotado aqui por $|L_i|$, é a quantidade de neurônios que ela possui. A camada $L_0$ é dita de entrada, na qual existe um **neurônio de entrada** para cada componente do vetor de entrada da rede. Esses neurônios se diferenciam dos demais porque,
ao invés de aceitarem todo o vetor, recebem apenas uma componente e simplesmente a **transmitem** para o interior da rede, sem
a aplicação da função de ativação. Já a camada $L_{C-1}$ é dita de saída,
e a sua resposta é a resposta da rede. As demais camadas são chamadas de **camadas escondidas**. Abaixo, a representação em gráfica da arquitetura de uma MLP:

O que viabiliza o funcionamento do modelo são as conexões entre essas camadas, e elas podem ocorrer de diversas maneiras na rede. Aqui,
o objeto de estudo são as **redes densas *feedforward***, em que cada neurônio da camada $L_i$ se conecta com
todos os neurônios da camada $L_{i+1}$, $i = 0,\ldots, C-2$, de maneira que o vetor de entrada dos neurônios da camada $L_{i+1}$ é o vetor composto pelas
respostas dos neurônios da camada $L_{i}$. Assim, um neurônio na camada $L_{i+1}$ recebe, como entrada, um vetor
de dimensão $|L_i|$, $i=0,1, \ldots, C-2$.

Cada conexão entre neurônios pode ser vista como associada ao peso que o neurônio
atribui ao valor por ela provido. Essa visão possibilita a utilização de uma
notação muito útil para se trabalhar com pesos: denota-se por $w_{kj}^l$ o peso
da conexão entre o neurônio $j$ da camada $l-1$ com o neurônio $k$ da camada $l$,
$l = 1, \ldots, C-1$. Ou seja:

Mais ainda, é possível representar todas as conexões entre
duas camadas por uma matriz de pesos $\mathbf{W}^l = (w_{kj}^l)$ de dimensão $|L_{l}| \times |L_{l-1}|$:

\begin{equation}
    \mathbf{W}^l =
    \begin{bmatrix}
        w^l_{11} & w^l_{12} & \ldots & w^l_{1|L_{l-1}|}\\
        w^l_{21} & w^l_{22} & \ldots & w^l_{2|L_{l-1}|}\\
        \vdots & \vdots & \ddots & \vdots\\
        w^l_{|L_l|1} & w^l_{|L_l|2} & \ldots & w^l_{|L_l||L_{l-1}|}\\
    \end{bmatrix}.
\end{equation}

Note que, com isso, a linha $k$ da matriz $\mathbf{W}^l$, denotada por $\mathbf{w}^l_k$, é o vetor de pesos
associados ao neurônio $k$ da camada $l$.

Para facilitar a identificação dos *biases* de cada neurônio em uma camada, denota-se por
$\mathbf{b}^l = \langle b^l_1, b^l_2, \ldots, b^l_{|L_l|} \rangle$ os *biases*
dos neurônios da camada $L_l$, sendo $b^l_i$ o *bias* do neurônio $i$ na camada $L_l$:

$$\mathbf{b}^l = \langle b^l_1, b^l_2, \ldots, b^l_{|L_{l}|} \rangle.$$

Sabendo disso, já é possível iniciar a implementação em Python da RNM!

### A classe `MultilayerNeuralNetwork`

Como o objetivo é
produzir um módulo reutilizável, os recursos de orientação a objetos da linguagem Python
serão aplicados. Além disso, o pacote `numpy` será utilizado para a manipulação de matrizes.

A programação tem início com a importação do `numpy` (ele foi importado antes, para o gráfico da sigmoide, mas repetimos aqui por questões didáticas) e a declaração da classe `MultilayerNeuralNetwork`, a qual encapsulará todos os dados e métodos necessários ao
funcionamento e à utilização da rede, como a matriz de pesos, a arquitetura e as rotinas de treinamento:

```python
import numpy as np
import random

class MultilayerNeuralNetwork:
    '''A Multilayer Dense Feedforward Neural Network implementation.'''
```

Essa é apenas uma classe, e nada tem a ver com redes neurais além do nome simplesmente. O próximo passo é criar um **construtor**, método que sempre será chamado quando alguém
criar (ou, mais tecnicamente, instanciar) a rede neural. Nele, é importante garantir
a criação e inicialização da arquitetura, o que implica na configuração das camadas (quantas e quantos neurônios devem possuir), das matrizes de pesos e dos vetores de *bias*.  Estas serão inicializadas com valores aleatórios, seguindo
a distribuição normal, e, em seguida, normalizadas pela raiz quadrada da quantidade de neurônios da camada correspondente. A inicialização dos pesos é um tópico importante, para o qual existem diversas propostas, mas que será abstraído neste momento por economia de tempo.
Além disso, um parâmetro `alpha` será acrescentado,
cuja existência será justificada mais adiante. Portanto, o construtor receberá do
usuário a **arquitetura da rede**, no formato $[|L_0|, |L_1|, \ldots, |L_{k-1}|$, e o tal `alpha`:

```python
    %%add_to MultilayerNeuralNetwork
    def __init__(self, arch = [1,2,1], alpha = 0.1):
        '''Initialize the network.'''

        # A dictionary to store the weight matrices
        self.W = {}
        # A dictionary to store the biases vectors
        self.B = {}
        # The learning rate (to be explained later)
        self.alpha = alpha
        # The arch answers the questions: how many layers and how many neurons in each layer.
        self.arch = arch

        # Initialize the weight matrix and biases with normalized random values
        for i in np.arange(1,len(self.arch)):
            # Weights
            w = np.random.randn(self.arch[i], self.arch[i-1])
            self.W[i] = w/np.sqrt(self.arch[i])
            # Biases
            b = np.random.randn(self.arch[i],1)
            self.B[i] = b/np.sqrt(self.arch[i])
```

Como será utilizada a função sigmoide para a ativação dos neurônios, é importante
que ela e sua derivada estejam implementadas na classe:

```python
    %%add_to MultilayerNeuralNetwork
    def sigmoid(self, x):
        '''Sigmoid function.'''
        return 1.0/(1 + np.exp(-x))

    def sigmoid_deriv(self, x):
        '''Derivative of the sigmoid function, considering that x is the result
        of applying the sigmoid function to the net.
        '''
        return x * (1 - x)
```

Com isso, é possível instanciar uma rede, mesmo que não se possa fazer nada com ela ainda, apenas para checar a sua estrutura:

```python
# Creating an architecture with 2 neurons in the input layer, 3 in the unique hidden layer
# and 1 in the output layer
neuralnet = MultilayerNeuralNetwork(arch = [2,3,1], alpha = 0.5)
print(neuralnet.W)
print(neuralnet.B)
```

    {1: array([[ 0.16182205, -0.03129429],
           [ 0.38264993,  0.25478651],
           [ 0.12592978, -0.77047449]]), 2: array([[-0.6471949 , -0.38644712,  0.8099297 ]])}
    {1: array([[-0.9829649 ],
           [ 0.16776619],
           [-0.16992502]]), 2: array([[-0.61045786]])}

### Respostas das camadas

Note que cada camada produz um vetor de resposta composto pelas respostas individuais
de cada um de seus neurônios. Denote por $\mathbf{a^{l-1}} = \langle a^{l-1}_1, a^{l-1}_2, \ldots, a^{l-1}_{|L_{l-1}|} \rangle$ esse vetor para a camada $L_{l-1}$. Como visto, ele deverá ser a entrada para a camada seguinte, $L_l$, mas os seus valores serão combinados de forma particular
pela matriz de pesos $\mathbf{W}^{l}$, como se pode reparar na imagem abaixo, quando analisada com a representação de um neurônio, exibida no início do *notebook*:

Assim, a expressão

$$\mathbf{z}^l = \mathbf{W}^{l}\cdot\mathbf{a}^{l-1} + \mathbf{b}^l$$

produz um vetor cujos componentes são os valores $net$ de cada neurônio da camada
$L_{l}$. Sabendo disso, qual seria a expressão para o vetor de saída da camada $L_{l}$, ou seja, para $\mathbf{a}^{l}$?
Basta aplicar a função de ativação a cada componente do vetor obtido! Considerando-se a aplicação da função ponto a ponto, ou seja, $f(\langle x_1, x_2, \ldots, x_n \rangle) = \langle f(x_1), f(x_2), \ldots, f(x_n) \rangle$, tem-se que:

$$\mathbf{a}^{l} = f(\mathbf{W}^{l}\cdot\mathbf{a}^{l-1} + \mathbf{b}^l) = f(\mathbf{z}^l).$$

O conhecimento adquirido até este ponto é suficiente para se entender a arquitetura de uma rede neural e como computar as saídas das suas camadas de neurônios a partir
de um vetor de entrada. Já podemos implementar uma função muito importante da nossa rede neural: dado um conjunto de vetores de entrada, qual é o vetor de resposta da rede para cada vetor, ou seja, qual o vetor na camada de saída? Essa função é comumente chamada de `predict`, pois é utilizada para gerar a previsão (resposta) da rede a partir de uma entrada. Note como é simples e advém diretamente da definição:

```python
    %%add_to MultilayerNeuralNetwork
    def predict(self, X):
        ''' Given a matrix X of input row vectors, compute the output of the network. '''
        # Set the result of the input layer to be the column vectors from X, transposed
        a = np.atleast_2d(X).T
        # Compute the output, passing through each layer
        for layer in np.arange(1, len(self.arch)):
            a = self.sigmoid(np.dot(self.W[layer], a) + self.B[layer])
        return a
```

Ocorre que apenas saber como obter a saída da rede não resolve problema algum: as matrizes de pesos aleatórios
tornam o modelo inútil. Ele necessita se ajustar (lê-se "aprender")
para solucionar os problemas com os quais é confrontado, e é disso que
trataremos a partir de agora!

# Aprendizado em uma RNM

O modelo de aprendizagem de redes neurais é geralmente o **supervisionado**, pois faz
uso de exemplos representantes da verdade (*ground truth*) para fazer
com que a rede gere as respostas desejadas após uma **etapa de treinamento**. É como se um professor
apresentasse a entrada, a rede respondesse e ele informasse qual foi o
erro cometido. A rede, com base nisso, modifica sua estrutura (suas matrizes
 de pesos) para que o erro, da próxima vez que o professor mostrar
 o exemplo, seja garantidamente menor. Esse processo termina quando algum critério de parada é atingido. Os mais comuns são o erro máximo e o número de ciclos ou *epochs* (percorrimentos do conjunto inteiro de treino).

 Um problema a ser resolvido com aprendizado de máquinas geralmente provê um conjunto com $N$ exemplos

 $$\mathcal{D} = \{\langle \mathbf{x_1}, \mathbf{y_1} \rangle\, \langle \mathbf{x_2}, \mathbf{y_2} \rangle\, \ldots, \langle \mathbf{x_N}, \mathbf{y_N} \rangle\}$$

 sendo $\mathbf{x_i} \in \mathbb{R}^d$ os vetores de entrada da rede e $\mathbf{y_i} \in \mathbb{R}^e$ a
 resposta esperada para a rede, também chamada de ***target*** ou **classe**.

 É prática comum
 particionar esse conjunto em dois outros: um para o treinamento da rede, denotado por $\mathcal{T}$,
 e outro para o teste do modelo treinado, denotado por $\mathcal{T^*}$. Tamanhos comuns para o conjunto de treino são $65\%$, $70\%$ ou $75\%$ de $|\mathcal{D}|$. A biblioteca `sklearn`, em Python,
 provê a função `train_test_split`, que realiza esse particionamento.

 O erro cometido pela rede, ao responder para dado exemplo $\langle \mathbf{x}, \mathbf{y} \rangle \in \mathcal{D}$, é modelado matematicamente por uma **função de custo**
 $C_x:\mathbb{R}^{\omega+\beta} \to \mathbb{R}$, onde $\omega$ é a quantidade de pesos (como calcular $\omega$ a partir da arquitetura?) e $\beta$, a de *biases*. As funções que podem desempenhar esse papel devem cumprir alguns requisitos
 básicos e dependem dos valores dos pesos e dos *biases*. Uma das mais comuns é a do **erro quadrático**, de equação:

 $$C_x = \frac 1 2 ||\mathbf{y}- \mathbf{a}^{k-1}(\mathbf{x})||^2 = \frac 1 2 \sum_i (y_i - a^{k-1}_i(\mathbf{x}))^2,$$
  onde $a^{k-1}(\mathbf{x})$ é a saída da última camada da rede para o exemplo $\langle \mathbf{x}, \mathbf{y} \rangle$ (que nossa rede já sabe computar). Note que,
 quanto maior a diferença entre a verdade e a saída da rede, maior o valor dessa
 função, o que mostra sua adequação para representar o erro.

 A fim de representar o erro com respeito a todos os exemplos, define-se também a função
 de custo $\bar{C}:\mathbb{R}^{\omega+\beta} \to \mathbb{R}$, dada pela média dos erros para cada exemplo, ou seja:

 $$
     \bar{C} = \frac 1 n \sum_{\langle \mathbf{x}, \mathbf{y} \rangle \in \mathcal{T}} C_x = \frac{1}{2n} \sum_{\langle \mathbf{x}, \mathbf{y} \rangle \in \mathcal{T}} ||\mathbf{y}- \mathbf{a}^{k-1}(\mathbf{x})||^2.
 $$

 Precisaremos, em nossa implementação, do cálculo da função de custo quadrática. Façamos, pois:

```python
    %%add_to MultilayerNeuralNetwork
    def quadratic_loss(self, X, Y):
        ''' Compute the total quadratic loss, given a matrix with row vectors. '''
        # Transform all target line vectors in column vectors
        Y = np.atleast_2d(Y).T
        # Generate predictions
        predictions = self.predict(X)
        # Compute loss
        n = X.shape[0]
        loss = (1/n) * 0.5 * np.sum((predictions - Y) ** 2)
        return loss
```

 O objetivo do treinamento é fazer com que o valor de $C_x$ (e $\bar{C}$, consequentemente), para cada exemplo $\langle \mathbf{x}, \mathbf{y} \rangle$,
 seja menor a cada ciclo de treino. Como $C_x$ é função dos pesos e dos *biases*, nada melhor que buscar ajustá-los a fim de alcançar esse objetivo. Esse ajuste ocorre por meio de algum
 **método de otimização**, cujo objetivo é encontrar um conjunto de valores para pesos e *biases* tal que o valor de $\bar{C}$ seja tão menor quanto se queira. Um método
 muito utilizado para tanto chama-se **gradiente descendente**, do qual a próxima seção tratará.

 ## Gradiente descendente

 Fujamos do escopo das redes neurais, e tratemos do problema geral de minimizar uma função
 de múltiplas variáveis $C:\mathbb{R}^n \to \mathbb{R}$. Queremos encontrar o vetor $\mathbf{v} = \langle v_1, v_2, \ldots, v_n \rangle$ tal que $C(\mathbf{v}) = C(v_1, v_2, \ldots, v_n)$ seja um mínimo global de $C$.

 Para conseguir a intuição sobre o método, vale reduzir a dimensão de entrada de $C$ para
 duas variáveis, $v_1$ e $v_2$, e imaginar que seu gráfico assuma a forma de um vale. Por exemplo:

```python
from mpl_toolkits import mplot3d

def quad(x,y):
    return x**2 + y**2

x = np.linspace(-10,10,30)
y = np.linspace(-10,10,30)

X,Y = np.meshgrid(x,y)

Z = quad(X,Y)

fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_surface(X,Y,Z,rstride=1, cstride=1, cmap='viridis', edgecolor='none')
ax.set_title('Quadratic plot')
```

A função acima é bem simples, devemos concordar, e, apenas observando seu gráfico, é possível encontrar o mínimo. Também, utilizando técnicas primárias de cálculo, é possível encontrar o mínimo global sem muito esforço nesse caso. Porém, quando lidamos com aprendizado, geralmente estamos interessados em minimizar funções de dezenas, centenas, milhares de variáveis! É por isso que as técnicas tradicionais de cálculo se tornam inviáveis, e outros métodos entram em cena.

O gradiente descendente, sendo um deles, não busca encontrar um mínimo global, mas sim **algum mínimo para a função**. Isso pode ser um problema em alguns casos, mas não se mostra um empecilho grave para o seu uso em muitas aplicações de redes neurais. Imagine uma bola colocada em algum ponto arbitrário do vale mostrado no gráfico acima. Nossa experiência diz que ela começará a rolar até o ponto mais baixo do vale, e esse fato nos dá uma heurística para encontrar um mínimo da função: encontremos uma maneira de mover a bola nas direções
$v_1$ e $v_2$ de forma que ela sempre esteja descendo o vale!

Já que queremos mover a bola, vamos representar o deslocamento por um vetor $\Delta v = \langle \Delta v_1, \Delta v_2 \rangle$. Assumindo a bola inicialmente na posição $v = \langle v_1, v_2 \rangle$, qual será a mudança no valor de $C$ causada por esse deslocamento? Ou seja, como podemos estimar o valor de $\Delta C = C(v+\Delta v)-C(v)$?
Da fórmula de Taylor para o caso Multivariável, temos que

$$\Delta C \approx \nabla C(v) \cdot \Delta v = \left \langle \frac{\partial C}{\partial v_1}, \frac{\partial C}{\partial v_2} \right \rangle \cdot \langle \Delta v_1, \Delta v_2 \rangle ^T = \frac{\partial C}{\partial v_1}\Delta v_1 + \frac{\partial C}{\partial v_2} \Delta v_2.$$

Queremos reduzir o valor de $C$, correto? Para isso, devemos escolher um valor de $\Delta v$ apropriado, tal que $\Delta C < 0$. Pela equação acima, isso se torna muito fácil! Vamos tomar $\Delta v$ como

$$\Delta v = -\alpha \nabla C^T,$$
onde $\alpha \in \mathbb{R}_+$. Ainda não convencido de que o $\Delta C$ causado por essa variação $\Delta v$ seja negativo? Vamos substituir:

$$\Delta C \approx \nabla C \cdot \Delta v = \nabla C \cdot (-\alpha \nabla C^T) = -\alpha (\nabla C \cdot \nabla C^T ) = -\alpha ||\nabla C||^2 \leq 0,$$
já que $||\nabla C||^2 \geq 0$ e $\alpha > 0$. Isso nos garante que, sempre que escolhermos o $\Delta v$ daquela forma, o valor do $C$ (o valor do erro!) sempre vai diminuir (claro, dentro dos limites da aproximação).

Observe que utilizamos, aqui, uma função de duas variáveis, como forma de simplificação. Porém, note que, nas equações vetoriais acima, esse fato não se mostrou nada relevante! De fato, não importa a dimensão: a regra do gradiente descendente funcionará, e é por isso que podemos utilizá-la em nossa rede neural! Assim, para reduzirmos o valor da função de custo $C$, alteraremos os pesos $\mathbf w$ e os biases $\mathbf b$ (as variáveis de $C$) pela regra abaixo, considerando a aplicação em cada variável:

$$w_k \gets w_k + (-\alpha \frac{\partial C}{\partial w_k})$$
$$b_l \gets b_l + (-\alpha \frac{\partial C}{\partial b_l})$$

No contexto das redes, o parâmetro $\alpha$, chamado **taxa de aprendizado** (*learning rate*), permite diminuir o risco de provocarmos um $\Delta C$ muito elevado, o que poderia representar uma fuga de um ponto mínimo, ou um muito pequeno, o que atrasaria o processo de otimização.

### O gradiente descendente estocástico

Suponha que nosso conjunto de treino $\mathcal{T}$ possua muitas instâncias. Como
$C = \sum_{x \in \mathcal{T}} C_x,$
computar $\nabla C$ seria muito demorado, pois envolveria computar todos os valores
$\nabla C_x$ e depois fazer $\nabla C = \frac{1}{n} \sum_{x \in \mathcal{T}} \nabla C_x$.
Isso poderia atrasar muito o aprendizado, pois haveria uma atualização dos pesos e *biases* por epoch!

O que comumente se faz é adaptar o método do gradiente descendente para sua versão
estocástica. Isso significa particionar aleatoriamente o conjunto $\mathcal T$ em uma família de conjuntos
$\{\mathcal{T_i}\}_i$ de mesma cardinalidade (tamanho) $m$. Seja $\mathcal{T_j} = \{x^j_1, x^j_2, \ldots, x^j_m\}$ algum desses conjuntos e suponha $m$ suficientemente grande.
Consideramos que a média entre os $\nabla C_{x^j_k}$ se aproxime da média dos $\nabla C_{x}$, ou seja:

$$\frac{\sum_{k = 1}^m \nabla C_{x^j_k}}{m} \approx \frac{\sum_{x \in \mathcal{T}} \nabla C_x}{n} = \nabla C.$$

Fantástico! Agora não precisamos esperar a análise do conjunto inteiro e potencialmente gigantesco $\mathcal{T}$ para que a rede aprenda! Podemos pegar porções menores (chamadas *mini-batches*), estimar $\nabla C$ utilizando a aproximação acima, e atualizar os pesos e *biases*! Na prática, tomamos um conjunto com $m$  pontos arbitrários, $\mathcal{T_j} = \{x^j_1, x^j_2, \ldots, x^j_m\}$, e executamos as atualizações:

$$w_k \gets w_k + (-\frac{\alpha}{m} \sum_{p = 1}^m \frac{\partial C_{x^j_p}}{\partial w_k})$$
$$b_l \gets b_l + (-\frac{\alpha}{m} \sum_{p = 1}^m \frac{\partial C_{x^j_p}}{\partial b_l})$$

Depois, tomamos outro conjunto de tamanho $m$,
e executamos as atualizações. Isso se repete até que o conjunto $\mathcal{T}$
se exaura, marcando o fim de uma *epoch* do treino. As próximas *epochs* repetirão esse processo.

Que tal implementarmos nosso *stochastic gradient descent* em nossa rede neural?

```python
    %%add_to MultilayerNeuralNetwork
    def stochastic_gradient_descent(self, D, batch_size):
        ''' Update the weights and bias through stochastic gradient
        descent optimization. '''
        # Get the amount of data points
        train_size = len(D)
        # Shuffle the data
        random.shuffle(D)
        # Obtain mini-batches
        mini_batches = [D[k : k + batch_size]
            for k in range(0, train_size, batch_size)]
        # Update weights and biases
        for mini_batch in mini_batches:
            self.optimize_mini_batch(mini_batch)

    def optimize_mini_batch(self, mini_batch):
        ''' Given a minibatch {<x1,y1>,<x2,y2>,...,<xm,ym>}, optimize the cost function. '''
        # Get the mini-batch size
        m = len(mini_batch)
        # Dictionaries to store the gradients of C
        GCw = {}
        GCb = {}
        # Initialize gradient matrices with zeroes
        for i in np.arange(1,len(self.arch)):
            GCw[i] = np.zeros(self.W[i].shape)
            GCb[i] = np.zeros(self.B[i].shape)
        # Use the SGD update rules for teaching the neural network for each batch point
        for x, y in mini_batch:
            # Compute gradients: see next section to understand how!
            Gw, Gb = self.compute_gradient(x, y)
            for i in np.arange(1,len(self.arch)):
                GCw[i] += Gw[i]
                GCb[i] += Gb[i]
        # Apply rules for updating weights and biases
        for l in np.arange(1,len(self.arch)):
            self.W[l] = self.W[l] - (self.alpha/m)*(GCw[l])
            self.B[l] = self.B[l] - (self.alpha/m)*(GCb[l])
```

Esta seção nos deu um método muito poderoso para nossa rede neural aprender
com a experiência! Existe, porém, um ponto ainda obscuro... **Como calcular o gradiente?** Ou melhor, como calcular de forma eficiente? O algoritmo da próxima seção tratará exatamente disso! Vamos aproveitar a estrutura em camadas da nossa rede para computar rapidamente gradientes de forma brilhante!

 ## O algoritmo Backpropagation

 Por questões de simplicidade, consideraremos um exemplo fixo $\langle \mathbf{x}, \mathbf{y} \rangle$, e chamaremos $C_x$ de $C$.

O ajuste dos pesos, pelo método do gradiente descendente, como visto, demanda o conhecimento sobre como variações nas matrizes
 de pesos - de camadas diferentes da última, inclusive - e dos *biases* afetam o resultado da função de custo. Em outras palavras, queremos conhecer as derivadas

 $$\frac{\partial C}{\partial w_{ij}^l} \qquad \frac{\partial C}{\partial b_{i}^l},$$
 para todos os $w_{ij}^l$ e $b_{i}^l$, o que nos permitirá utilizar as regras de atualização do gradiente descendente e fazer a rede neural aprender.

 Computá-los de forma explícita seria dispendioso. Existe, porém, um caminho implícito. Imagine que o neurônio $j$ da camada $L_l$
 deva produzir o valor $net$ denotado por $z_j^l$. Porém, suponha que esse valor
 seja alterado por uma quantidade pequena $\Delta z_j^l$, de tal forma que a resposta dele
 seja $f(z_j^l + \Delta z_j^l)$. Como a função de custo é afetada por essa mudança?
 Da fórmula de Taylor para o Cálculo Multivariável, chega-se que

 $$\Delta C = \frac{\partial C}{\partial z_{j}^l} \Delta z_{j}^l.$$

 Note que, se $\frac{\partial C}{\partial z_{j}^l}$
 possuir um valor alto, é necessário um $\Delta z_{j}^l$ de sinal oposto
 para reduzir o custo. Se possuir um valor próximo de zero,
 como $\Delta z_j^l$ é pequeno, não se pode fazer muito para reduzir o custo, e o neurônio é dito estar
 em estado próximo do ótimo. Assim, faz sentido definir o erro nesse neurônio,
 denotado por $\delta_j^l$, como

 \begin{equation}
 \delta_j^l = \frac{\partial C}{\partial z_{j}^l}.
 \end{equation}

 Além disso, define-se o vetor de erros dos neurônios da camada $L_l$ por
 $\delta^l = \langle \delta^l_1, \delta^l_2, \ldots, \delta^l_{|L_l|} \rangle$. O algoritmo Backpropagation está fundamentado em quatro equações, das quais se pode obter
 o gradiente da função de custo, de interesse para as regras de atualização do gradiente descendente. Pode-se dizer que este é o grande viabilizador das redes neurais modernas, incluindo
as utilizadas para *Deep Learning*. Para simplificar a notação, considere a camada $L_{k-1}$ (de saída) denotada pelo índice $L$.

Resta-nos compreender cada equação.

### Uma equação para o $\delta^L$

Esta equação fornece meios para se calcular o erro na camada de saída. A função de custo pode ser vista da forma

$$C=C(X_1, X_2, \ldots, X_{|L|}),$$

tal que $X_1 = a_1^L(z^L_1, z^L_2 \ldots, z^L_{|L|}), X_2 = a_2^L(z^L_1, z^L_2 \ldots, z^L_{|L|}), \ldots, X_{|L|} = a_{|L|}^L(z^L_1, z^L_2 \ldots, z^L_{|L|})$. Pela definição anterior, sabemos que:

$$\delta_j^L = \frac{\partial C}{\partial z_{j}^L}.$$

Utilizando a **regra da cadeia** do Cálculo Multivariável, tem-se que

$$\frac{\partial C}{\partial z_{j}^L} = \sum_k \frac{\partial C}{\partial a^L_j}\frac{\partial a^L_k}{\partial z^L_j}.$$

Como apenas $a^L_j$ depende de $z^L_j$, todas as $\frac{\partial a^L_k}{\partial z^L_j}$, com $k \neq j$, serão anuladas, restando que:

$$\frac{\partial C}{\partial z_{j}^L} =\frac{\partial C}{\partial a^L_j}\frac{\partial a^L_j}{\partial z^L_j}.$$

Como $a^L_j = f(z^L_j)$,

$$\frac{\partial a^L_j}{\partial z^L_j} = f'(z^L_j).$$

Finalmente, chega-se à equação

$$\delta^L_j = \frac{\partial C}{\partial a^L_j}f'(z_j^L).$$

Na forma vetorial, temos:

$$\delta^L = \nabla C_a \odot f'(\mathbf{z}^L),$$
onde $\odot$ é o **produto de Hadamard**, o qual tem o seguinte efeito, para dois vetores:

$$\langle a_1, a_2, \ldots, a_n \rangle^T \odot \langle b_1, b_2, \ldots, b_n \rangle^T = \langle a_1b_1, a_2b_2,\ldots,a_nb_n \rangle^T.$$

### Uma equação para o $\delta^l$ em função de $\delta^{l+1}$

Na equação passada, conseguimos uma forma de calcular os erros da última camada, entretanto, queremos também um modo
de se calcularem os erros nas demais camadas. A estratégia é tentar escrever $\delta^{l}_j = \frac{\partial C}{\partial z^l_j}$ em termos de $\delta^{l+1}_{j} = \frac{\partial C}{\partial z^{l+1}_j}$. Como? Primeiro, é válido notar que $z_j^{l+1}$, por definição,
depende $z_j^l$:

$$z_j^{l+1} = \mathbf{W}^{l+1}_ja^l = \mathbf{W}^{l+1}_jf(z^l) = \sum_i w^{l+1}_{ij}f(z^l_i).$$

Além disso, $C$ depende de $z_j^{l+1}$, devido à definição recursiva de $a^n$. Com isso, basta aplicar a regra da cadeia novamente, e temos que:

$$\delta_j^l = \sum_k \frac{\partial C}{\partial z^{l+1}_k}\frac{\partial z^{l+1}_k}{\partial z^l_j} = \sum_k \frac{\partial z^{l+1}_k}{\partial z^l_k}\delta^{l+1}_k.$$

Diferenciando a expressão para $z_j^{l+1}$, temos que:
$$\frac{\partial z_k^{l+1}}{\partial z_j^l} = w^{l+1}_{jk}f'(z^l_j).$$

Assim, substituindo, chegamos a:
$$\delta_j^l = \sum_k w^{l+1}_{jk}\delta^{l+1}_kf'(z^l_j).$$

Podemos obter a versão vetorial da seguinte forma:

\begin{align}
\delta_j^l = & (\sum_k w^{l+1}_{kj}\delta^{l+1}_k)f'(z^l_j) \\
           = & ((w^{l+1}_{j})^T \cdot \delta^{l+1})f'(z^l_j)
\end{align}

Essa é a forma componente a componente; para obtermos o vetor $\delta_j$, basta
utilizarmos o produto de Hadamard:

$$\delta^l = ((w^{l+1})^T \cdot \delta^{l+1}) \odot f'(z^l)$$

### Uma equação para $\frac{\partial C}{\partial b^l_{j}}$

Chegou a hora de obtermos as nossas derivadas de interesse! Começaremos por aquelas
com respeito aos *biases*. Da regra da cadeira para o cálculo de muitas variáveis, temos que:

$$\frac{\partial C}{\partial b^l_{j}}=\sum_m \frac{\partial C}{\partial z_m^l} \frac{\partial z_m^l}{\partial b_{j}^l}$$

Note agora que $\frac{\partial z_m^l}{\partial b_{j}^l}$, quando $m \neq j$,
é zero, pois $z_m^l$ não depende de $b_{j}^l$ nessas condições. Assim, a equação acima
se reduz a:

$$\frac{\partial C}{\partial b^l_{j}}=\frac{\partial C}{\partial z_j^l} \frac{\partial z_j^l}{\partial b_{j}^l}$$

Vamos lembrar a forma de $z_j^l$:

$$z_j^l = \sum_k a^{l-1}_k w^l_{kj} + b^l_j$$

Daí, derivando $z_j^l$ com respeito a $b^l_j$, temos:

$$\frac{\partial z_j^l}{\partial b_{j}^l} = 1.$$

Substituindo na equação mais acima, chegamos que, simplesmente:

$$\frac{\partial C}{\partial b^l_{j}}=\frac{\partial C}{\partial z_j^l} = \delta^l_j.$$

### Uma equação para $\frac{\partial C}{\partial w^l_{jk}}$

Sabemos que $C$ está em função de $z^l$ e que $z^l$ está em função dos pesos,
o que inclui $w_{jk}^l$. Por essa razão, podemos novamente aplicar a regra
da cadeia, da seguinte forma:
$$\frac{\partial C}{\partial w^l_{jk}}=\sum_m \frac{\partial C}{\partial z_m^l} \frac{\partial z_m^l}{\partial w_{jk}^l}$$

Note que $w^l_{jk}$ apenas influencia no cálculo de $z^l_j$, logo
$$\frac{\partial C}{\partial w^l_{jk}}=\frac{\partial C}{\partial z_j^l} \frac{\partial z_j^l}{\partial w_{jk}^l}=\delta^l_j\frac{\partial z_j^l}{\partial w_{jk}^l}.$$

Lembremo-nos de que:
$$z_j^{l} = \mathbf{W^{l}}_j \cdot \mathbf{a}^{l-1} = \sum_i w^{l}_{ji}a^{l-1}_i,$$
onde $\mathbf{W^{l}}_j$ é a $j$-ésima linha de $\mathbf{W}^l$.

Com isso, diferenciando, temos:
$$\frac{\partial z_j^{l}}{\partial w_{jk}^l} =  a^{l-1}_k.$$

Finalmente, substituindo:
$$\frac{\partial C}{\partial w^l_{jk}}= \delta^l_j a^{l-1}_k.$$

Temos, assim, todas as equações fundamentais que constituem o algoritmo
Backpropagation, e elas nos dão todas as derivadas parciais de que precisamos para treinar nossa rede com o gradiente descendente! Note que o algoritmo Backpropagation é um método geral de computar derivadas parciais em grafos como os utilizados para representar MLPs, e, portanto, pode ser utilizado em outros métodos de otimização!

Agora, resta implementarmos. Antes de tudo, é preciso observarmos que, nas equações acima, são utilizados
os valores dos outputs de cada camada, ou seja, os $\mathbf{a}^l, l=1,\ldots,k-1$, e, portanto,
precisamos calculá-los. Esse processo é comumente referenciado como **forward pass**, e funciona como
a função `predict`, porém trabalha apenas sobre uma instância e retorna os *outputs* intermediários
da rede:

```python
    %%add_to MultilayerNeuralNetwork
    def forward_pass(self, x):
        ''' Given a line vector x, compute the output of each layers. '''
        # Set the result of the input layer to be column vectors
        a = np.atleast_2d(x).T
        # Compute and store the output of each layer, following the definition
        A = {}
        A[0] = a
        for layer in np.arange(1, len(self.arch)):
            a = self.sigmoid(np.dot(self.W[layer], a) + self.B[layer])
            A[layer] = a
        return A
```

Com isso, podemos calcular os gradientes executando primeiro o `forward_pass` e, em seguida, utilizando
as quatro equações discutidas acima:

```python
    %%add_to MultilayerNeuralNetwork
    def compute_gradient(self, x, y):
        ''' Use backpropagation equations to compute the gradient of the cost function. '''
        # The index of the output layer, L
        L = len(self.arch) - 1
        # Dicts for gradients
        Gw = {}
        Gb = {}
        # Compute the network output for x input sample
        A = self.forward_pass(x)
        # Declare what will be set of delta vectors
        D = {}
        # Compute the error for the last neurons, using Equation 1
        y = np.atleast_2d(y).T
        deltaL = (A[L] - y) * self.sigmoid_deriv(A[L])
        # Include in the D matrix
        D[L] = deltaL
        # Compute each delta{l} = g(delta{l+1}), using Equation 2
        for l in np.arange(L-1, 0, -1):
            D[l] = (self.W[l+1].T.dot(D[l+1])) * self.sigmoid_deriv(A[l])
        for l in np.arange(L, 0, -1):
            # Compute derivatives with respect to biases, using Equation 3
            Gb[l] = D[l]
            # Compute derivatives with respect to weights, using Equation 4
            Gw[l] = D[l].dot(A[l-1].T)
        # Return all derivatives
        return (Gw, Gb)
```

Nós temos agora todos os componentes para construir uma função de treino, que chamaremos de `fit`. Ela, basicamente, executará todos os métodos criados acima, informando ao usuário dados importantes sobre o processo:

```python
    %%add_to MultilayerNeuralNetwork
    def fit(self, X, Y, batch_size = 20, epochs = 1000, max_loss = 0.001, displayUpdate = 100):
        ''' Train the neural network '''
        # Assemble T dataset
        D = list(zip(X,Y))
        # Store losses for returning
        losses = [self.quadratic_loss(X,Y)]
        # Loop over epochs
        current_epoch = 0
        while current_epoch < epochs and losses[current_epoch] > max_loss:
            # Optimize using gradient descent
            self.stochastic_gradient_descent(D, batch_size)
            # Compute and store loss
            losses.append(self.quadratic_loss(X,Y))
            # Print information
            if current_epoch % displayUpdate == 0:
                print("Epoch: {}, Loss: {:.7f}".format(current_epoch + 1, losses[current_epoch]))
            current_epoch += 1
        # Print about stop reason
        if losses[current_epoch] < max_loss:
            print("Stopped by max loss criterion, loss: {:.7f}.", losses[current_epoch])
        elif current_epoch == epochs:
            print("Stopped by epochs criterion.")
        # Return training information
        return (losses, current_epoch)
```

# Utilizando a rede neural

Agora que temos nossa rede neural implementada, podemos resolver alguns problemas bastante interessantes. Aqui serão mostrados dois exemplos: o XOR e o reconhecimento de caracteres utilizando o *dataset* MNIST.

## XOR

XOR é uma operação bem conhecida da lógica proposicional que possui a seguinte tabela verdade:

|$x_1$|$x_2$|$y$
|---|---|---|
|0|0|0|
|1|0|1|
|0|1|1|
|1|1|0|

A partir disso, tem-se o *dataset* XOR, composto pelos vetores:
$\langle 0,0 \rangle, \langle 1,0 \rangle, \langle 0,1 \rangle, \langle 1,1 \rangle$,
com os respectivos *labels* $0, 1 , 1, 0$, correspondendo a cada linha da tabela acima. O que acontece se plotarmos
$x_1$ no eixo $x$, $x_2$ no eixo $y$ e atribuirmos uma cor para cada um dos dois valores possíveis para $y$? Note:

```python
import matplotlib.pyplot as plt

x1 = np.array([[0],[1],[0],[1]])
x2 = np.array([[0],[0],[1],[1]])

plt.scatter(x1,x2,c=['green','red','red','green'])
plt.title('The XOR dataset')
plt.show()
```

O que há de importante nesse *dataset*? **Não é possível separá-lo utilizando uma reta**! Ou seja, não corresponde a um problema de classificação com classes linearmente separáveis. Um neurônio somente não conseguiria resolvê-lo, mas nossa rede pode! Vamos?

```python
# Let's have our dataset D
X = np.array([[1,1],[1,0],[0,1],[0,0]])
# Let's have our targets, following the truth table presented above
y = np.array([[0],[1],[1],[0]])

# Now, let's create and train the neural network!
xorNetwork = MultilayerNeuralNetwork(arch=[2,2,1], alpha=0.5)
(losses, last_epoch) = xorNetwork.fit(X, y, batch_size=4, epochs = 20000, displayUpdate=100, max_loss=0.001)
```

    Epoch: 1, Loss: 0.1457950
    Epoch: 101, Loss: 0.1255456
    Epoch: 201, Loss: 0.1243842
    Epoch: 301, Loss: 0.1232247
    Epoch: 401, Loss: 0.1219474
    Epoch: 501, Loss: 0.1204335
    Epoch: 601, Loss: 0.1185733
    Epoch: 701, Loss: 0.1162880
    Epoch: 801, Loss: 0.1135529
    Epoch: 901, Loss: 0.1104072
    Epoch: 1001, Loss: 0.1069338
    Epoch: 1101, Loss: 0.1032101
    Epoch: 1201, Loss: 0.0992473
    Epoch: 1301, Loss: 0.0949427
    Epoch: 1401, Loss: 0.0900672
    Epoch: 1501, Loss: 0.0843169
    Epoch: 1601, Loss: 0.0774604
    Epoch: 1701, Loss: 0.0695468
    Epoch: 1801, Loss: 0.0610262
    Epoch: 1901, Loss: 0.0526098
    Epoch: 2001, Loss: 0.0449340
    Epoch: 2101, Loss: 0.0383314
    Epoch: 2201, Loss: 0.0328452
    Epoch: 2301, Loss: 0.0283583
    Epoch: 2401, Loss: 0.0247026
    Epoch: 2501, Loss: 0.0217145
    Epoch: 2601, Loss: 0.0192557
    Epoch: 2701, Loss: 0.0172151
    Epoch: 2801, Loss: 0.0155064
    Epoch: 2901, Loss: 0.0140626
    Epoch: 3001, Loss: 0.0128320
    Epoch: 3101, Loss: 0.0117746
    Epoch: 3201, Loss: 0.0108589
    Epoch: 3301, Loss: 0.0100602
    Epoch: 3401, Loss: 0.0093590
    Epoch: 3501, Loss: 0.0087395
    Epoch: 3601, Loss: 0.0081891
    Epoch: 3701, Loss: 0.0076976
    Epoch: 3801, Loss: 0.0072565
    Epoch: 3901, Loss: 0.0068588
    Epoch: 4001, Loss: 0.0064987
    Epoch: 4101, Loss: 0.0061715
    Epoch: 4201, Loss: 0.0058730
    Epoch: 4301, Loss: 0.0055997
    Epoch: 4401, Loss: 0.0053488
    Epoch: 4501, Loss: 0.0051178
    Epoch: 4601, Loss: 0.0049044
    Epoch: 4701, Loss: 0.0047068
    Epoch: 4801, Loss: 0.0045234
    Epoch: 4901, Loss: 0.0043528
    Epoch: 5001, Loss: 0.0041937
    Epoch: 5101, Loss: 0.0040450
    Epoch: 5201, Loss: 0.0039059
    Epoch: 5301, Loss: 0.0037753
    Epoch: 5401, Loss: 0.0036527
    Epoch: 5501, Loss: 0.0035373
    Epoch: 5601, Loss: 0.0034285
    Epoch: 5701, Loss: 0.0033258
    Epoch: 5801, Loss: 0.0032287
    Epoch: 5901, Loss: 0.0031368
    Epoch: 6001, Loss: 0.0030496
    Epoch: 6101, Loss: 0.0029669
    Epoch: 6201, Loss: 0.0028883
    Epoch: 6301, Loss: 0.0028136
    Epoch: 6401, Loss: 0.0027424
    Epoch: 6501, Loss: 0.0026745
    Epoch: 6601, Loss: 0.0026097
    Epoch: 6701, Loss: 0.0025478
    Epoch: 6801, Loss: 0.0024886
    Epoch: 6901, Loss: 0.0024320
    Epoch: 7001, Loss: 0.0023778
    Epoch: 7101, Loss: 0.0023258
    Epoch: 7201, Loss: 0.0022759
    Epoch: 7301, Loss: 0.0022280
    Epoch: 7401, Loss: 0.0021820
    Epoch: 7501, Loss: 0.0021377
    Epoch: 7601, Loss: 0.0020951
    Epoch: 7701, Loss: 0.0020541
    Epoch: 7801, Loss: 0.0020146
    Epoch: 7901, Loss: 0.0019766
    Epoch: 8001, Loss: 0.0019398
    Epoch: 8101, Loss: 0.0019044
    Epoch: 8201, Loss: 0.0018701
    Epoch: 8301, Loss: 0.0018370
    Epoch: 8401, Loss: 0.0018050
    Epoch: 8501, Loss: 0.0017741
    Epoch: 8601, Loss: 0.0017441
    Epoch: 8701, Loss: 0.0017151
    Epoch: 8801, Loss: 0.0016871
    Epoch: 8901, Loss: 0.0016598
    Epoch: 9001, Loss: 0.0016334
    Epoch: 9101, Loss: 0.0016078
    Epoch: 9201, Loss: 0.0015830
    Epoch: 9301, Loss: 0.0015588
    Epoch: 9401, Loss: 0.0015354
    Epoch: 9501, Loss: 0.0015126
    Epoch: 9601, Loss: 0.0014905
    Epoch: 9701, Loss: 0.0014690
    Epoch: 9801, Loss: 0.0014481
    Epoch: 9901, Loss: 0.0014277
    Epoch: 10001, Loss: 0.0014079
    Epoch: 10101, Loss: 0.0013886
    Epoch: 10201, Loss: 0.0013698
    Epoch: 10301, Loss: 0.0013515
    Epoch: 10401, Loss: 0.0013336
    Epoch: 10501, Loss: 0.0013162
    Epoch: 10601, Loss: 0.0012992
    Epoch: 10701, Loss: 0.0012827
    Epoch: 10801, Loss: 0.0012665
    Epoch: 10901, Loss: 0.0012507
    Epoch: 11001, Loss: 0.0012353
    Epoch: 11101, Loss: 0.0012203
    Epoch: 11201, Loss: 0.0012056
    Epoch: 11301, Loss: 0.0011913
    Epoch: 11401, Loss: 0.0011772
    Epoch: 11501, Loss: 0.0011635
    Epoch: 11601, Loss: 0.0011501
    Epoch: 11701, Loss: 0.0011370
    Epoch: 11801, Loss: 0.0011242
    Epoch: 11901, Loss: 0.0011116
    Epoch: 12001, Loss: 0.0010993
    Epoch: 12101, Loss: 0.0010873
    Epoch: 12201, Loss: 0.0010755
    Epoch: 12301, Loss: 0.0010640
    Epoch: 12401, Loss: 0.0010527
    Epoch: 12501, Loss: 0.0010416
    Epoch: 12601, Loss: 0.0010308
    Epoch: 12701, Loss: 0.0010201
    Epoch: 12801, Loss: 0.0010097
    Stopped by max loss criterion, loss: {:.7f}. 0.000999918950218

Podemos checar o aprendizado por meio de um gráfico, visto que a função `fit` retorna uma lista com os valores dos custos em cada *epoch*:

```python
# List epochs (x-axis)
epochs = np.arange(0, last_epoch + 1)
# Plot losses
plt.title("Loss per epoch - XOR dataset")
plt.plot(epochs, losses)
plt.show()
```

Com nosso modelo treinado, podemos checar se ele, de fato, resolveu o problema do XOR, ou seja, se ele consegue classificar (separar) corretamente os pontos segundo a tabela verdade:

```python
# Obtain predictions
pred = xorNetwork.predict(X)
print("Network output: " + str(pred))
# Round values for getting the targets we expect
print("In target format: " + str(np.round(pred)))
```

    Network output: [[ 0.03883568  0.95161933  0.95217085  0.04316045]]
    In target format: [[ 0.  1.  1.  0.]]

## MNIST

O MNIST é um *dataset* massivamente utilizado pelos estudiosos de Machine Learning composto
por imagens de dígitos de 0 a 9. Ele possui 60000 exemplos de treino e 10000 de testes.
Os vetores de características são 784-dimensionais ($28 \times 28$ pixels por imagem),
com componentes assumindo valores em $[0,255]$. O propósito é corretamente classificar
os dígitos desse *dataset*!

A biblioteca `sklearn` oferece uma amostra do MNIST por meio de comandos simples.
Para concluir este *notebook*, vamos utilizá-los para obter uma parte desse *dataset* e, com ela,
treinar uma rede neural especializada em classificar
imagens de dígitos de 0 a 9! Repare que o código é praticamente o mesmo para o treinamento que fizemos
com o *dataset* XOR:

```python
# Import sklearn tools
from sklearn.preprocessing import LabelBinarizer
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
from sklearn import datasets

# Load a sample of MNIST through simple sklearn commands
print("[INFO] loading MNIST dataset")
digits = datasets.load_digits()
data = digits.data.astype("float")
data = (data - data.min()) / (data.max() - data.min())    # normalize data
print("[INFO] samples: {}, dim: {}".format(data.shape[0], data.shape[1]))

# Split the dataset into train and test
(trainX, testX, trainY, testY) = train_test_split(data, digits.target, test_size=0.25)

# Binarize labels, such that, for example, the target 5 becomes the row vector (0,0,0,0,0,1,0,0,0,0)
trainY = LabelBinarizer().fit_transform(trainY)
testY = LabelBinarizer().fit_transform(testY)

# Train the neural network
print("[INFO] training network...")
mnistNN = MultilayerNeuralNetwork(arch=[trainX.shape[1], 32, 16, 10], alpha=0.1)
(losses, last_epoch) = mnistNN.fit(trainX, trainY, epochs=100, batch_size=1, max_loss=0.00001, displayUpdate=10)
```

    [INFO] loading MNIST dataset
    [INFO] samples: 1797, dim: 64
    [INFO] training network...
    Epoch: 1, Loss: 1.5819163
    Epoch: 11, Loss: 0.1432953
    Epoch: 21, Loss: 0.0436310
    Epoch: 31, Loss: 0.0253904
    Epoch: 41, Loss: 0.0178175
    Epoch: 51, Loss: 0.0130532
    Epoch: 61, Loss: 0.0094908
    Epoch: 71, Loss: 0.0073073
    Epoch: 81, Loss: 0.0056128
    Epoch: 91, Loss: 0.0043927
    Stopped by epochs criterion.

Observemos também o gráfico do custo por *epoch*:

```python
epochs = np.arange(0, last_epoch + 1)

plt.plot(epochs, losses)
plt.show()
```

Uma vez treinada a rede, podemos checar seu desempenho no conjunto de testes, por meio da função `classification_report`, que fornece índices de desempenho:

```python
print("[INFO] evaluating network...")
# Test the model
print(testX.shape)
# Obtain predictions
predictions = mnistNN.predict(testX)
# Transform binary vectors in digits 0-9
predictions = predictions.T.argmax(axis=1)
# Print classification report
print(classification_report(testY.argmax(axis=1), predictions))
```

    [INFO] evaluating network...
    (450, 64)
                 precision    recall  f1-score   support

              0       1.00      1.00      1.00        44
              1       0.93      1.00      0.97        43
              2       0.93      1.00      0.96        38
              3       0.97      0.92      0.94        36
              4       0.98      0.98      0.98        49
              5       0.97      0.93      0.95        41
              6       1.00      0.98      0.99        47
              7       0.97      0.95      0.96        60
              8       0.93      0.95      0.94        43
              9       0.96      0.94      0.95        49

    avg / total       0.97      0.96      0.96       450

A imagem abaixo [3] elucida os índices **precision** e **recall** exibidos no relatório acima:

Já o **f1-score** é a média harmônica entre o *precision* e o *recall*:

$$\text{f1-score} = \frac{2}{\frac{1}{\text{recall}} + \frac{1}{\text{precision}}}.$$

# Referências

[1] Michael Nielsen. *Neural Networks and Deep Learning*. Determination Press. Disponível em: http://neuralnetworksanddeeplearning.com/

[2] Adrian Rosebrock. *Deep Learning for Computer Vision with Python*. 1st ed. https://www.pyimagesearch.com: PyImageSearch.

[3] F1-score. Wikipedia. https://en.wikipedia.org/wiki/F1_score