# Partials etc involving matricies

## Preliminaries

```python
#%matplotlib widget
%matplotlib inline
%load_ext autoreload
%autoreload 2
```

```python
import numpy as np
import matplotlib.pyplot as plt
import sympy
```

### A few ways to get test numpy arrays

```python
np.arange(3), np.arange(4,8), np.arange(5,1,-2)
```

    (array([0, 1, 2]), array([4, 5, 6, 7]), array([5, 3]))

For experiments with multiplication, arrays of primes may be helpful:

```python
def arangep(n, starting_index=0):
    sympy.sieve.extend_to_no(starting_index + n)
    return np.array(sympy.sieve._list[starting_index:starting_index + n])
```

```python
arangep(5), arangep(4,2)
```

    (array([ 2,  3,  5,  7, 11]), array([ 5,  7, 11, 13]))

```python
M = arangep(4).reshape(2,2)
x = arangep(2,4)
# x = np.arange(2)+1
M,x
```

    (array([[2, 3],
            [5, 7]]),
     array([11, 13]))

## Einstein summation notation

Numpy provides [Einstein summation](https://mathworld.wolfram.com/EinsteinSummation.html) operations with [einsum](https://numpy.org/devdocs/reference/generated/numpy.einsum.html)
1. Repeated indices are implicitly summed over.
1. Each index can appear at most twice in any term.
1. Each term must contain identical non-repeated indices.

```python
es = np.einsum
```

 $$a_{ik}a_{ij} \equiv \sum_{i} a_{ik}a_{ij}$$

```python
es('ij,j', M, x), es('ij,i', M, x)
```

    (array([ 61, 146]), array([ 87, 124]))

___

# Partials

## Preliminaries

A matrix __M__ multiplies a (column) vector __x__ to its right to produce a (column) vector __y__:
$$ \normalsize \mathbf{M} \mathbf{x} = \mathbf{y} $$
where
$$ \normalsize
\mathbf{x} = \sum_{j=1}^{n} x_j \mathbf{\hat{x}}_j \\
\mathbf{y} = \sum_{i=1}^{m} y_i \mathbf{\hat{y}}_i
$$
and $\mathbf{M}$ can be written
$$ \normalsize
\begin{bmatrix}
    m_{1,1} & \dots & m_{1,n} \\
    \vdots & \ddots & \vdots \\
    m_{m,1} & \dots & m_{m,n}
\end{bmatrix} \\
$$

A `python` example:

```python
y = M @ x
y
```

    array([ 61, 146])

Using Einstein summation notation, $y_i = m_{ij}x_j$

```python
np.einsum('ij,j', M, x)
```

    array([ 61, 146])

## Partial derivative of a matrix multiply of a vector

Wikipedia [defines](https://en.wikipedia.org/wiki/Partial_derivative#Formal_definition) the partial derivative thus: \
Let _U_ be an open subset of $\mathbb{R}^n$ and ${\displaystyle f:U\to \mathbb {R} }$ a function. The partial derivative of _f_ at the point ${\displaystyle \mathbf {a} =(a_{1},\ldots ,a_{n})\in U}$ with respect to the _i_-th variable $x_i$ is defined as

$$
\begin{align}
\frac{\partial }{\partial x_i }f(\mathbf{a}) & = \lim_{h \to 0} \frac{f(a_1, \ldots , a_{i-1}, a_i+h, a_{i+1}, \ldots ,a_n) -
f(a_1, \ldots, a_i, \dots ,a_n)}{h} \\
& = \lim_{h \to 0} \frac{f(\mathbf{a}+he_i) -
f(\mathbf{a})}{h} \tag{2.1}
\end{align}
$$

Where $f(\mathbf{a})$ is linear, $f(\mathbf{a}+he_i) = f(\mathbf{a}) + f(he_i) = f(\mathbf{a}) + h f(e_i)$, and we have
$$ \begin{align} \\
\frac{\partial }{\partial x_i }f(\mathbf{a}) &= \lim_{h \to 0} \frac{f(\mathbf{a}+he_i) - f(\mathbf{a})}{h} \\
 & = \lim_{h \to 0} \frac{f(\mathbf{a}) + h f(e_i) - f(\mathbf{a})}{h} \\
 & = \lim_{h \to 0} \frac{h f(e_i)}{h} \\
 & = \lim_{h \to 0} {f(e_i)} \\
 &= f(e_i) \tag{2.2}
\end{align}
$$

### $\partial\mathbf{y} / \partial\mathbf{x}$

How does vector $\mathbf{y}$ vary with vector $\mathbf{x}$, with $M$ held constant? I.e. what is $\partial\mathbf{y}/\partial\mathbf{x}$?

With
$$ %\normalsize
\mathbf{x} = \sum_{j=1}^{n} x_j \mathbf{\hat{x}}_j, \;\;
\mathbf{y} = \sum_{i=1}^{m} y_i \mathbf{\hat{y}}_i
$$

The matrix equation $\mathbf{y} = \mathbf{M} \mathbf{x}$ can be written as
$$ \normalsize
\begin{align}
\mathbf{y} &= \sum_i y_i \mathbf{\hat{y}}_i
  = \mathbf{M}\mathbf{x}  \tag{2.3} \label{mmul}
\end{align}
$$
where
$$ \normalsize
\begin{align}
y_i &= f_i(x_1, x_2, \dots x_n) \\[6pt]
  &= \sum_j m_{ij}x_j \tag{2.4}
\end{align}
$$

We have
$$ \normalsize
\begin{align}
 \frac{\partial\mathbf{y}}{\partial\mathbf{x}}
 &= \frac{\partial\sum_{i=1}^{m} y_i \mathbf{\hat{y}}_i}{\partial\mathbf{x}} \\[10pt]
 &= \frac{\partial\sum_{i=1}^{m} f_i(x_1, x_2, \dots x_n) \mathbf{\hat{y}}_i}{\partial\mathbf{x}} \\[10pt]
 &= \sum_{i=1}^{m} \frac{\sum_{j=1}^{n} \partial(m_{ij}x_j) \mathbf{\hat{y}}_i}{{\partial x_j} \mathbf{\hat{x}_j}} \\[10pt]
 &= \sum_{i=1}^{m}
     \sum_{j=1}^{n}
      \frac{\partial(m_{ij}x_j)}
           {\partial x_j}
        \frac{\mathbf{\hat{y}}_i}{\mathbf{\hat{x}_j}}  \\[10pt]
 &= \sum_{i=1}^{m}
     \sum_{j=1}^{n} m_{ij}
      \frac{\partial x_j}
           {\partial x_j}
        \frac{\mathbf{\hat{y}}_i}{\mathbf{\hat{x}_j}}  \\[10pt]
 &= \sum_{i=1}^{m}
     \sum_{j=1}^{n} m_{ij}
      \frac{\mathbf{\hat{y}}_i}{\mathbf{\hat{x}_j}}  \\[10pt]
\end{align}
$$

The basis vectors for $\partial\mathbf{y} / \partial\mathbf{x}$ are $\mathbf{\hat{y}}_i / \mathbf{\hat{x}_j}$. We can array the components in a matrix to say \
\
$$ \normalsize
\frac{\partial \mathbf{y}}{\partial \mathbf{x}} =
%\large
\begin{bmatrix}
m_{1,1}\frac{\mathbf{\hat{y}}_1}{\mathbf{\hat{x}_1}} & \cdots &
m_{1,n}\frac{\mathbf{\hat{y}}_1}{\mathbf{\hat{x}_n}} \\
\vdots & \ddots & \vdots \\
m_{m,1}\frac{\mathbf{\hat{y}}_n}{\mathbf{\hat{x}_1}} & \cdots &
m_{m,n}\frac{\mathbf{\hat{y}}_m}{\mathbf{\hat{x}_n}}
\end{bmatrix}
$$

Then
\
$$ \normalsize
\partial \mathbf{y} =
%\large
\begin{bmatrix}
m_{1,1}\frac{\mathbf{\hat{y}}_1}{\mathbf{\hat{x}_1}} & \cdots &
m_{1,n}\frac{\mathbf{\hat{y}}_1}{\mathbf{\hat{x}_n}} \\
\vdots & \ddots & \vdots \\
m_{m,1}\frac{\mathbf{\hat{y}}_n}{\mathbf{\hat{x}_1}} & \cdots &
m_{m,n}\frac{\mathbf{\hat{y}}_m}{\mathbf{\hat{x}_n}}
\end{bmatrix}
\partial \mathbf{x}
$$
and
$$ \normalsize
\begin{align}
\partial \mathbf{x} &=
%\large
\begin{bmatrix}
m_{1,1}\frac{\mathbf{\hat{y}}_1}{\mathbf{\hat{x}_1}} & \cdots &
m_{1,n}\frac{\mathbf{\hat{y}}_1}{\mathbf{\hat{x}_n}} \\
\vdots & \ddots & \vdots \\
m_{m,1}\frac{\mathbf{\hat{y}}_n}{\mathbf{\hat{x}_1}} & \cdots &
m_{m,n}\frac{\mathbf{\hat{y}}_m}{\mathbf{\hat{x}_n}}
\end{bmatrix}^\mathsf{T}
\partial\mathbf{y} \\[10pt]
&=
%\large
\begin{bmatrix}
m_{1,1}\frac{\mathbf{\hat{x}}_1}{\mathbf{\hat{y}_1}} & \cdots &
m_{m,1}\frac{\mathbf{\hat{x}}_1}{\mathbf{\hat{y}_m}} \\
\vdots & \ddots & \vdots \\
m_{1,n}\frac{\mathbf{\hat{x}}_n}{\mathbf{\hat{y}_1}} & \cdots &
m_{m,n}\frac{\mathbf{\hat{x}}_n}{\mathbf{\hat{y}_m}}
\end{bmatrix}
\partial\mathbf{y}
\end{align}
$$

Approximating ([2.1](#mjx-eqn-partial)) numerically with our example:

```python
M, (M@(x + np.array([0.001, 0])) - M@x) / 0.001, (M@(x + np.array([0, 0.001])) - M@x) / 0.001
```

    (array([[2, 3],
            [5, 7]]),
     array([2., 5.]),
     array([3., 7.]))

Test (2.5) numerically:

```python
max(err.dot(err)
    for err in (((M@(x + veps) - M@x) - M@veps)
              for M,x,veps in ((np.random.randn(2,2), np.random.randn(2), np.random.randn(2) * 0.001)
                          for i in range(1000))))
```

    1.4982115870801231e-30

### $\partial\mathbf{y} / \partial\mathbf{M}$

How does vector $\mathbf{y}$ vary with matrix $M$, with vector $\mathbf{x}$ held constant? I.e. what is $\partial\mathbf{y}/\partial\mathbf{M}$?

From (2.3):
$$\begin{align}
 y_i &= \sum_j m_{ij}x_j \\
 \partial y_i &= \sum_j \partial m_{ij}x_j \\
% \frac{\partial y_i}{\partial M_{ij}} &= 2
\end{align}
$$

Then _[explain]_
$$
 \partial\mathbf{y} = \partial\mathbf{M}\mathbf{x} \\
 \frac{\partial\mathbf{y}}{\partial\mathbf{M}} = \mathbf{x}
$$

Numeric demonstration

```python
M, x, M@x
```

    (array([[2, 3],
            [5, 7]]),
     array([11, 13]),
     array([ 61, 146]))

```python
k11 = np.array([[1, 0], [0, 0]])
k12 = np.fliplr(k11)
k21 = np.flipud(k11)
k22 = np.fliplr(k21)
singles = (k11, k12, k21, k22)
singles
```

    (array([[1, 0],
            [0, 0]]),
     array([[0, 1],
            [0, 0]]),
     array([[0, 0],
            [1, 0]]),
     array([[0, 0],
            [0, 1]]))

```python
[((M+(e*0.001))@x - M@x) / 0.001 for e in singles]
```

    [array([11.,  0.]), array([13.,  0.]), array([ 0., 11.]), array([ 0., 13.])]

```python
[e@x for e in singles]
```

    [array([11,  0]), array([13,  0]), array([ 0, 11]), array([ 0, 13])]

Test numerically: Create random vector x and random M and dM matricies. Use an approximation of (2.1) to estimate
$\partial\mathbf{y}/\partial\mathbf{M}$ numerically, and compare to $\partial\mathbf{M}\mathbf{x}$. Find the maximum squared error in a number of random trials.

```python
max(v.dot(v)
    for v in (dM@x - (((M+(dM*0.001))@x - M@x) / 0.001)
              for M,dM,x in ((np.random.randn(2,2), np.random.randn(2,2), np.random.randn(2))
                          for i in range(1000))))
```

    2.4641463207114085e-24

## Gradient

From [Wikipedia](https://en.wikipedia.org/wiki/Gradient):

In vector calculus, the **gradient** of a scalar-valued differentiable function $f$ of several variables is the vector field (or vector-valued function) $\nabla f$ whose value at a point $p$ is the vector whose components are the partial derivatives of $f$ at $p$.

That is, for $f \colon \mathbf{R}^n \to \mathbf{R}$, its gradient $\nabla f \colon \mathbf{R}^n \to \mathbf{R}^n$ is defined at the point $p = (x_1,\ldots,x_n)$ in *n-*dimensional space as the vector:

$$\nabla f(p) = \begin{bmatrix}\frac{\partial f}{\partial x_1}(p) \\ \vdots \\ \frac{\partial f}{\partial x_n}(p) \end{bmatrix}.$$

Strictly speaking, the gradient is a vector field $f \colon \mathbf{R}^n \to T\mathbf{R}^n$, and the value of the gradient at a point is a tangent vector in the tangent space at that point, $T_p \mathbf{R}^n$, not a vector in the original space $\mathbf{R}^n$. However, all the tangent spaces can be naturally identified with the original space $\mathbf{R}^n$, so these do not need to be distinguished.

${\displaystyle \nabla f(p)\cdot \mathrm {v} = {\tfrac {\partial f}{\partial \mathbf {v} }}(p)=df_{\mathrm {v} }(p)}$

Computationally, given a tangent vector, the vector can be _multiplied_ by the derivative (as matrices), which is equal to taking the dot product with the gradient: \
${\displaystyle (df_{p})(v)={\begin{bmatrix}{\frac {\partial f}{\partial x_{1}}}(p)\cdots {\frac {\partial f}{\partial x_{n}}}(p)\end{bmatrix}}{\begin{bmatrix}v_{1}\\\vdots \\v_{n}\end{bmatrix}}=\sum _{i=1}^{n}{\frac {\partial f}{\partial x_{i}}}(p)v_{i}={\begin{bmatrix}{\frac {\partial f}{\partial x_{1}}}(p)\\\vdots \\{\frac {\partial f}{\partial x_{n}}}(p)\end{bmatrix}}\cdot {\begin{bmatrix}v_{1}\\\vdots \\v_{n}\end{bmatrix}}=\nabla f(p)\cdot v}
$

In Euclidian 3-space,
$$ \nabla\phi(x, y, z) =
\frac{\partial\phi}{\partial x}\mathbf{\hat{x}} +
\frac{\partial\phi}{\partial y}\mathbf{\hat{y}} +
\frac{\partial\phi}{\partial z}\mathbf{\hat{z}}
$$

# Numerical Approximations

```python
if __name__ == '__main__':
    class VC():
        def grad(f, x, eps=1e-6):
            #epsihat = np.eye(x.size) * eps
            epsihat = np.eye(x.shape[-1]) * eps
            yp = np.apply_along_axis(f, 1, x + epsihat)
            ym = np.apply_along_axis(f, 1, x - epsihat)
            return (yp - ym)/(2 * eps)

        def tensor_grad(f, x, eps=1e-6):
            return np.apply_along_axis(lambda v: VC.grad(f, v, eps), 1, x)
```

## Examples

### gradient

of a constant scalar $f(x) = c$

```python
VC.grad(lambda x: 42, np.array([3]))
```

    array([0.])

of a scalar polynomial $x(1-x) = -x^2 + x$

```python
VC.grad(lambda x: x * (1-x), np.array([3]))
```

    array([[-5.]])

of an element-wise multiply by a constant vector:

```python
f = lambda v: np.multiply(v, np.arange(v.size) + 1)
VC.grad(f, np.arange(4))
```

    array([[1., 0., 0., 0.],
           [0., 2., 0., 0.],
           [0., 0., 3., 0.],
           [0., 0., 0., 4.]])

of a matrix multiply. Here's a non-square matrix:

```python
v = np.random.rand(3)
np.arange(v.size * (v.size+1)).reshape((v.size, v.size+1))
```

    array([[ 0,  1,  2,  3],
           [ 4,  5,  6,  7],
           [ 8,  9, 10, 11]])

The gradient of the matrix multiplication at a given point:

```python
f = lambda v: v @ np.arange(v.size * (v.size+1)).reshape((v.size, v.size+1))
x = np.arange(3)
y = f(x)
g = VC.grad(f, x)
x, y, g
```

    (array([0, 1, 2]),
     array([20, 23, 26, 29]),
     array([[ 0.,  1.,  2.,  3.],
            [ 4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11.]]))

The gradient of an affine transformation:

```python
f = lambda v: v @ np.arange(v.size * (v.size+1)).reshape((v.size, v.size+1)) + np.arange(v.size+1)
x = np.arange(3)
y = f(x)
g = VC.grad(f, x)
x, y, g
```

    (array([0, 1, 2]),
     array([20, 24, 28, 32]),
     array([[ 0.,  1.,  2.,  3.],
            [ 4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11.]]))

```python
x @ g, x.dot(g), g @ y
```

    (array([20., 23., 26., 29.]),
     array([20., 23., 26., 29.]),
     array([162.00000006, 554.00000003, 946.00000009]))

### Gradient back-propagation

Consider a loss function $\displaystyle loss(\mathbf{x}) = \frac{\| \mathbf{x} - \mathbf{x}_{ideal}\|}{2}$

```python
ideal = np.array([2,3,5])
loss = lambda v: (v - ideal).dot(v - ideal) / 2.0
```

The gradient of this loss function at $\mathbf{x} = (-2, 0, 1) \circ \mathbf{\hat x}$

```python
y = np.array([-2,0,1])
loss_at_y = loss(y)
g = VC.grad(loss, y)
y, loss_at_y, g
```

    (array([-2,  0,  1]), 20.5, array([-4., -3., -4.]))

```python
f = lambda v: v @ np.arange(v.size * (v.size+1)).reshape((v.size, v.size+1)) + np.arange(v.size+1)
x = np.array([-1,1])
y = f(x)
loss_at_y = loss(y)
print(f"x = {x}, y = {y}, loss at y = {loss_at_y}")
print(f"∇𝑙𝑜𝑠𝑠(𝑦) = {VC.grad(loss, y)}")
print(f"∇𝑙𝑜𝑠𝑠(𝑥) = {VC.grad(lambda x:loss(f(x)), x)}")
g_at_x = VC.grad(f, x)
```

    x = [-1  1], y = [3 4 5], loss at y = 1.0
    ∇𝑙𝑜𝑠𝑠(𝑦) = [1. 1. 0.]
    ∇𝑙𝑜𝑠𝑠(𝑥) = [1. 7.]

$\nabla loss(x)$

# END
---

```python

```


# Weak Formulation

Equation (b) is a vector equation, so really it is two or three equations involving multiple components of $\vec{j}$. We want to work with a single scalar equation, allow for anisotropic physical properties, and potentially work with non-axis-aligned meshes — how do we do this?! We can use the **weak formulation** where we take the inner product ($\int \vec{a} \cdot \vec{b} dv$) of the equation with a generic face function, $\vec{f}$. This reduces requirements of differentiability on the original equation and also allows us to consider tensor anisotropy or curvilinear meshes (Haber 2014).

## Scalar equations only, please

In Figure 5, we visually walk through the discretization of equation (b). On the left hand side, a dot product requires a *single* cartesian vector, $[\mathbf{j_x, j_y}]$. However, we have a $j$ defined on each face (2 $j_x$ and 2 $j_y$ in 2D!). There are many different ways to evaluate this inner product: we could approximate the integral using trapezoidal, midpoint or higher order approximations. A simple method is to break the integral into four sections (or 8 in 3D) and apply the midpoint rule for each section using the closest $\mathbf{j}$ components to compose a cartesian vector. A $\mathbf{P}_i$ matrix (size 2 × 4) is used to pick out the appropriate faces and compose the corresponding vector (these matrices are shown with colors corresponding to the appropriate face in the figure). On the right hand side, we use a vector identity to integrate by parts. The second term will cancel over the entire mesh (as the normals of adjacent cell faces point in opposite directions) and $\phi$ on mesh boundary faces are zero by the Dirichlet boundary condition. This leaves us with the  divergence, [which we already know how to do](divergence.ipynb)!

<p style="text-align:center;">
**Figure 5**: Discretization using the weak formulation and inner products.
</p>

The final step is to recognize that, now discretized, we can cancel the general face function $\mathbf{f}$ and transpose the result (for convention's sake):

$$
\frac{1}{4}\sum_{i=1}^{4} \mathbf{P}_i^\top \sqrt{v} \boldsymbol{\Sigma}^{-1} \sqrt{v} \mathbf{P}_i \mathbf{j} = \mathbf{D}^\top v \phi
$$

# Implementation

We will start by using a SimPEG Mesh that only has a single cell.

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

```python
fig, ax = plt.subplots(1)
mesh = Mesh.TensorMesh([1,1])
mesh.plotGrid(ax=ax,centers=True,faces=True)
ax.set_xlim(-0.5,1.5)
ax.set_ylim(-0.5,1.5);
```

The figure above is of a single cell, which in 2D has four faces (the green triangles). Two of these are pointed in the x direction and two in the y direction.

For this single cell, we need our projection matrices to pick out four cartesian vectors that we will then use in evaluating the full integral.

Instead of creating the code from scratch in this notebook, we will leverage some of the internal functions that are in SimPEG. The code is straight forward, but can get a little bit intricate when working with multiple dimensions and indexing in a generalized way (i.e. works for all of the meshes).

If you ever want to see the exact code that is executed in a function, you can use two question marks (one for the docs only). For example, execute **`mesh._getFacePxx??`** and Jupyter will show you more than you ever wanted. :)

```python
# mesh._getFacePxx??
```

This function creates the P matrices that we need. The matrix just pick out the appropriate faces and are used later on to evaluate our full integral.

```python
# face-x minus side and the face-y minus side
P1 = mesh._getFacePxx()('fXm','fYm')
print( P1.todense() )
```

    [[ 1.  0.  0.  0.]
     [ 0.  0.  1.  0.]]

We can create and plot all of the matrices that were in Figure 5:

```python
P1 = mesh._getFacePxx()('fXm','fYm')
P2 = mesh._getFacePxx()('fXp','fYm')
P3 = mesh._getFacePxx()('fXm','fYp')
P4 = mesh._getFacePxx()('fXp','fYp')
fig, ax = plt.subplots(1,4, figsize=(15,3))
def plot_projection(ii, ax, P):
    ax.spy(P, ms=30)
    if P.shape[1]==4:
        ax.set_xticks(range(4))
        ax.set_xticklabels(('x-','x+','y-','y+'))
    ax.set_xlabel('P{}'.format(ii+1))
map(plot_projection, range(4), ax, (P1, P2, P3, P4));
```

This assumes that we unwrapped our face vectors in certain way and they are ordered [xfaces, yfaces].

# All the cells!

When we move to more cells the visualization of the matrix is not as clear, so instead we will show a connection from each cell center to the x and y faces that are selected by the P matrix.

```python
mesh = Mesh.TensorMesh([3,4])
```

```python
P1 = mesh._getFacePxx()('fXm','fYm')
P2 = mesh._getFacePxx()('fXp','fYm')
P3 = mesh._getFacePxx()('fXm','fYp')
P4 = mesh._getFacePxx()('fXp','fYp')
fig, ax = plt.subplots(1,4, figsize=(15,6))
def plot_projection(ii, ax, P):
    x = P * np.r_[mesh.gridFx[:,0], mesh.gridFy[:,0]]
    y = P * np.r_[mesh.gridFx[:,1], mesh.gridFy[:,1]]
    xx, xy = x[:mesh.nC], x[mesh.nC:]
    yx, yy = y[:mesh.nC], y[mesh.nC:]
    ax.plot(np.c_[xx, mesh.gridCC[:,0], xx*np.nan].flatten(), np.c_[yx, mesh.gridCC[:,1], yx*np.nan].flatten(), 'k-')
    ax.plot(np.c_[xy, mesh.gridCC[:,0], xy*np.nan].flatten(), np.c_[yy, mesh.gridCC[:,1], yy*np.nan].flatten(), 'k-')
    ax.plot(xx, yx, 'g>')
    ax.plot(xy, yy, 'g^')
    mesh.plotGrid(ax=ax, centers=True)
    ax.set_title('P{}'.format(ii+1))
map(plot_projection, range(4), ax.flatten(), (P1, P2, P3, P4));
plt.tight_layout()
```

Each cell (the center is the red dot) has two faces that it uses, one x component and one y component.

The four $P$ matrices are assembled and summed in the **getFaceInnerProduct** code using this formula:

$$
\mathbf{M}_f(\sigma) = \frac{1}{4}\sum_{i=1}^{4} \mathbf{P}_i^\top \sqrt{v} \boldsymbol{\Sigma}^{-1} \sqrt{v} \mathbf{P}_i
$$

This function takes a sigma variable which can have any sort of anisotropy:

$$
\begin{align}\begin{aligned}\begin{split}\vec{\mu} = \left[\begin{matrix} \mu_{1} & 0 \\ 0 & \mu_{1} \end{matrix}\right]\end{split}\\\begin{split}\vec{\mu} = \left[\begin{matrix} \mu_{1} & 0 \\ 0 & \mu_{2} \end{matrix}\right]\end{split}\\\begin{split}\vec{\mu} = \left[\begin{matrix} \mu_{1} & \mu_{3} \\ \mu_{3} & \mu_{2} \end{matrix}\right]\end{split}\end{aligned}\end{align}
$$

The matrices that are produced by this function can be seen below!

```python
isotropic       = np.ones(mesh.nC)*4
anisotropic_vec = np.r_[np.ones(mesh.nC)*4, np.ones(mesh.nC)]
anisotropic     = np.r_[np.ones(mesh.nC)*4, np.ones(mesh.nC), np.ones(mesh.nC)*3]
Mf_sig_i = mesh.getFaceInnerProduct(isotropic)
Mf_sig_v = mesh.getFaceInnerProduct(anisotropic_vec)
Mf_sig_a = mesh.getFaceInnerProduct(anisotropic)
clim = (
    np.min(map(np.min, (Mf_sig_i.data, Mf_sig_v.data, Mf_sig_a.data))),
    np.max(map(np.max, (Mf_sig_i.data, Mf_sig_v.data, Mf_sig_a.data)))
)
fig, ax = plt.subplots(1,3, figsize=(15,4))
def plot_spy(ax, Mf, title):
    dense = Mf.toarray()
    dense[dense == 0] = np.nan
    ms = ax.matshow(dense)#, clim=clim)
    plt.colorbar(ms, ax=ax)
    ax.set_xlabel(title)
map(plot_spy, ax, (Mf_sig_i, Mf_sig_v, Mf_sig_a), ('Isotropic', 'Coordinate Anisotropy', 'Full Anisotropy'));
```

In the last figure you can see that there is coupling between the cells in x and y due to the non-coordinate aligned anisotropy. This will make the matrix system harder to solve when we try to invert it!

# Ok, but does it work?

Unfortunately the testing of the innerproducts is not that visual. We need to make sure that the integral of some made up function evaluates to the correct value.

So to make something up, and integrate it, we will use **sympy**!

We will evaluate the integral:
$$
\int_0^1 \int_0^1 \sigma(x,y) \vec{j}(x,y) \cdot \vec{j}(x,y) \quad dx dy
$$
by making up some ridiculous functions.

```python
import sympy
from sympy.abc import x, y

# Here we will make up some j vectors that vary in space
j = sympy.Matrix([
    x**2+y*5,
    (5**2)*x+y*5
])

# Create an isotropic sigma vector
Sig = sympy.Matrix([
    [x*y*432/1163,      0      ],
    [     0      , x*y*432/1163]
])

# Do the inner product!
jTSj = j.T*Sig*j
ans  = sympy.integrate(sympy.integrate(jTSj, (x,0,1)), (y,0,1))[0] # The `[0]` is to make it an int.

print( "It is trivial to see that the answer is {}.".format(ans) )
```

    It is trivial to see that the answer is 42.

Wonderful. Everything seems right in the world with that answer. Next step is to sub in the locations of the grid on both the faces in x and y as well as the cell centers.

```python
def get_vectors(mesh):
    """Gets the vectors sig and [jx, jy] from sympy."""
    f_jx  = sympy.lambdify((x,y), j[0], 'numpy')
    f_jy  = sympy.lambdify((x,y), j[1], 'numpy')
    f_sig = sympy.lambdify((x,y), Sig[0], 'numpy')
    jx  = f_jx(mesh.gridFx[:,0], mesh.gridFx[:,1])
    jy  = f_jy(mesh.gridFy[:,0], mesh.gridFy[:,1])
    sig = f_sig(mesh.gridCC[:,0], mesh.gridCC[:,1])
    return sig, np.r_[jx, jy]
```

Using these vectors ($\sigma$ and $\mathbf{j}$) we can evaluate the result on our mesh

```python
n = 5 # get's better if you add cells!
mesh = Mesh.TensorMesh([n,n])
sig, jv = get_vectors(mesh)
Msig = mesh.getFaceInnerProduct(sig)
numeric_ans = jv.T.dot(Msig.dot(jv))
print( "Numerically we get {}.".format(numeric_ans) )
```

    Numerically we get 41.1891755804.

That is pretty close, but to be rigorous we should look at the order of the convergence which should be $\mathcal{O}(h^2)$.

SimPEG has a number of testing functions for
[derivatives](http://docs.simpeg.xyz/content/api_core/api_Tests.html#SimPEG.Tests.checkDerivative)
and
[order of convergence](http://docs.simpeg.xyz/content/api_core/api_Tests.html#SimPEG.Tests.OrderTest)
that make this pretty simple!

```python
import sys
import unittest
from SimPEG.Tests import OrderTest

class Testify(OrderTest):
    meshDimension = 2
    def getError(self):
        sig, jv = get_vectors(self.M)
        Msig = self.M.getFaceInnerProduct(sig)
        return float(ans) - jv.T.dot(Msig.dot(jv))
    def test_order(self):
        self.orderTest()

# This just runs the unittest:
suite = unittest.TestLoader().loadTestsFromTestCase( Testify )
unittest.TextTestRunner().run( suite );
```

    .

    uniformTensorMesh:  Order Test
    _____________________________________________
       h  |    error    | e(i-1)/e(i) |  order
    ~~~~~~|~~~~~~~~~~~~~|~~~~~~~~~~~~~|~~~~~~~~~~
       4  |  1.27e+00   |
       8  |  3.17e-01   |   3.9942    |  1.9979
      16  |  7.93e-02   |   3.9985    |  1.9995
      32  |  1.98e-02   |   3.9996    |  1.9999
    ---------------------------------------------
    Awesome, Rowan, just awesome.

    ----------------------------------------------------------------------
    Ran 1 test in 0.028s

    OK

These basic operations on the mesh for inner products and differential operators are core pieces of SimPEG, and they need to be tested continuously to ensure that no one has broken them!

On every single change to the SimPEG codebase many hours of testing are completed!

If you would like to browse through the results of 424 tests that are run on the mesh classes you can see them here:

https://travis-ci.org/simpeg/simpeg/jobs/148608973

# Next up ...

In the [next notebook](all_together_now.ipynb) we will use our knowledge of the [mesh](mesh.ipynb) and the [divergence](divergence.ipynb) implementations to bring the DC resistivity equations together and show some pretty figures!


```javascript
%%javascript
MathJax.Hub.Config({
    TeX: { equationNumbers: { autoNumber: "AMS" } }
});
```

    <IPython.core.display.Javascript object>

## Setup

To start, some simplifications:

1. The robot is currently a point. Not a spherical cow, sadly. 🐮
2. The ropes all meet at the robot point.
3. The ropes are all taut, and aren't elastic.
4. The pulleys are also points.

Let:

- The dimensions of the box made by the pulleys, and their supporting rods, be $B\times D \times H$.
- The four pulleys be $P_i | i \in {1, 2, 3, 4}$.
  - So the positions of the four pulleys are:
    $P_1 = (0, 0, H)$,
    $P_2 = (0, D, H)$,
    $P_3 = (B, D, H)$, and
    $P_4 = (B, 0, H)$.
- The length of the the rope from pulley $P_i$ be $l_i$.
- The position of the robot be $(x, y, z)$.

## Relating the locations and lengths

The length of each rope $l_i$ is the distance from the corresponding pulley $P_i$ to the robot:
\begin{align}
\begin{aligned}
l_1^2 &= (0 - x)^2 + (0 - y)^2 + (H - z)^2 \\ &= x^2 + y^2 + (H - z)^2
\end{aligned} \\
\begin{aligned}
l_2^2 &= (0 - x)^2 + (D - y)^2 + (H - z)^2 \\ &= x^2 + (D - y)^2 + (H - z)^2
\end{aligned} \\
\begin{aligned}
l_3^2 &= (B - x)^2 + (D - y)^2 + (H - z)^2 \\ &= (B - x)^2 + (D - y)^2 + (H - z)^2
\end{aligned} \\
\begin{aligned}
l_4^2 &= (B - x)^2 + (0 - y)^2 + (H - z)^2 \\ &= (B - x)^2 + y^2 + (H - z)^2
\end{aligned} \\
\end{align}

We can then get equations featuring only $x$ or only $y$, by subtracting $l_2$ from $l_1$ and $l_3$ from $l_2$:

\begin{align}
\begin{aligned}
l_1^2 - l_2^2 &= y^2 - (D - y)^2 \\
 &= y^2 - (D^2 - 2Dy + y^2) \\
 &= -D^2 + 2Dy
\end{aligned} \\
\begin{aligned}
l_2^2 - l_3^2 &= x^2 - (B - x)^2 \\
 &= x^2 - (B^2 - 2By + x^2) \\
 &= -B^2 + 2Bx
\end{aligned} \\
\end{align}

So:

\begin{align}
y &= \frac{l_1^2 - l_2^2 + D^2}{2D} \\
x &= \frac{l_2^2 - l_3^2 + B^2}{2B}
\end{align}

Or by other combinations as well:

\begin{align}
l_4^2 - l_3^2 &= y^2 - (D - y)^2 \\
l_1^2 - l_4^2 &= x^2 - (B - x)^2
\end{align}

Giving us a constraint on why we don't have full 6DOF:

\begin{align}
l_1^2 - l_2^2 &= l_4^2 - l_3^2 \\
\end{align}

We can substitute back $x$ and $y$ to get $z$ as a function of $(l_i, B, D, H, x, y)$.

For example, if we substitute $x$ & $y$ in $l_1$ equation, then we get (since this a sq equation, hence 2 solutions)

$z = 0.5(\sqrt(-l_1^4D^2 + 2l_1^2l_2^2D^2 - 2l_1^2D^4 + 4l_1^2 - l_2^4B^2 - l_2^4D^2 - 2l_2^2B^4 + 2l_2^2B^2l_3^2 + 2l_2^2D^4 - B^6 + 2B^4l_3^2 - B^2l_3^4 - D^6) + 2 H)$

## Adding more realism

Now, we can add some complications. First, the pulley is now a pulley and not a dot. We can then say that each length $l_i$ is an inital length $L_i$ plus the length added by unwinding the pulley via rotation, $r_i\Delta\theta_i$. (We're still simplifying by assuming the radius remains constant - in reality, it will change as the rope gets wound or unwound.)

### Tautness
As long as the rope is taut, the lengths will be equal to the distances from equations 1 to 4:

\begin{align}
l_i = L_i + r_i\Delta\theta_i
\end{align}

Other simplifications:

- Identical pulleys.
- The initial position of the robot is set so that the ropes are all equal length (so, at the intersection of the diagonals of the rectangle formed by the pulleys).

Then $L_i$ are all $\frac{1}{2}\sqrt{B^2 + D^2}$.

Using $\Theta$ to denote the tuple of rotations $(\theta_1, \theta_2, \theta_3, \theta_4)$:

$$
(x, y, z)  = f(\Theta, B, D, H)
$$

# Two models

The ultimate goal is to get a sequences of rotations of the pulleys that get us from point A $(x_1, y_1, z_1) = f(\Theta_1, B, D, H)$ to point B $(x_2, y_2, z_2) = f(\Theta_2, B, D, H)$.

This can be thought of as a two step process:

1. Get total rotation needed $\Theta_2 - \Theta_1 = \Delta\Theta = (\Delta\theta_1, \Delta\theta_2, \Delta\theta_3, \Delta\theta_4)$.
2. Break that down in to a sequence of small rotations $\{(d\theta_1, d\theta_2, d\theta_3, d\theta_4)_1, (d\theta_1, d\theta_2, d\theta_3, d\theta_4)_2, \ldots\}$, satisfying various constraints:
 - the path is smooth,
 - the ropes don't get too much slack,
 - the robot doesn't get too tilted.

Each can be independently modeled.

## Model 1: total movement
For the first model, we can start using the initial assumptions we made, since we can generate training data from the shown equations. This will let us quickly get a reasonable model.

We can then expand it to account for the simplifications we made:

- the robot can be expanded to a platform, with $x, y, z$ as the centre, and distance equations 1-4 are modified to account for the dimensions of the platform,
- the pulleys can be different,
- the pulley radii of vary with rotation,
- the rope has some elasticity, etc.

## Model 2: sequence of steps

For the second model, further consideration is needed. Possibilities:

- Since we are using stepper motors, we can consider $\Delta\theta_i$ to be $N_{steps, i} \times d\theta_{step, i}$, with $d\theta_{step, i}$ being constant for each pulley/motor.
- Then, instead of $\Delta\Theta$, we can work with the total number of steps needed for each motor: $(N_1, N_2, N_3, N_4)$.
- And we can break down the total number of steps a sequence of smaller steps where each motor moves one step, or not at all: $(1, 0, 1, 0), (0, 0, 1, 0), \ldots$.
- At each step, we ensure that the rope is taut (equation 12), and if any rope gets too much slack, prioritize rotating that pulley to accomodate make the rope taut.

```python

```


# Double Pendulum #

In this notebook, we are going to develop the EoMs for Double Pendulum system. We are going to derive EoMs, simulate them and finally visualize them.

The code is loaded in the notebook using `%load` IPython magic, to avoid duplication of the code.

```
#import the code from double_pendulum.py
%load double_pendulum.py
```

```
from sympy import symbols
from sympy.physics.mechanics import *

q1, q2 = dynamicsymbols('q1 q2')
q1d, q2d = dynamicsymbols('q1 q2', 1)
u1, u2 = dynamicsymbols('u1 u2')
u1d, u2d = dynamicsymbols('u1 u2', 1)
l, m, g = symbols('l m g')

N = ReferenceFrame('N')
A = N.orientnew('A', 'Axis', [q1, N.z])
B = N.orientnew('B', 'Axis', [q2, N.z])

A.set_ang_vel(N, u1 * N.z)
B.set_ang_vel(N, u2 * N.z)

O = Point('O')
P = O.locatenew('P', l * A.x)
R = P.locatenew('R', l * B.x)

O.set_vel(N, 0)
P.v2pt_theory(O, N, A)
R.v2pt_theory(P, N, B)

ParP = Particle('ParP', P, m)
ParR = Particle('ParR', R, m)

kd = [q1d - u1, q2d - u2]
FL = [(P, m * g * N.x), (R, m * g * N.x)]
BL = [ParP, ParR]

KM = KanesMethod(N, q_ind=[q1, q2], u_ind=[u1, u2], kd_eqs=kd)

KM.kanes_equations(FL, BL)
#kdd = KM.kindiffdict()
#mass_matrix = KM.mass_matrix_full
#forcing_vector = KM.forcing_full
#qudots = mass_matrix.inv() * forcing_vector
#qudots = qudots.subs(kdd)
#qudots.simplify()
#

```

```
#import the code from simulate.py
%load simulate.py
```

```
"""
This file will use pydy.codegen to simulate the double pendulum.

"""

from numpy import concatenate, array, linspace
from pydy.system import System
from scipy.integrate import odeint

from double_pendulum import *

constants = {l: 10.0, m: 10.0, g: 9.81}

initial_conditions = {q1: 1.0, q2: 0.0, u1: 0.0, u2: 0.0}

sys = System(KM, constants=constants,
        initial_conditions=initial_conditions)

frames_per_sec = 60
final_time = 5.0

times = linspace(0.0, final_time, final_time * frames_per_sec)
sys.times = times
x = sys.integrate()

```

```
#Import the code from visualize.py
%load visualize.py
```

```
"""
This file will use pydy.viz to visualize the double pendulum.  Run this script
via a command line:

    $ python visualization.py

"""

from numpy import pi

from pydy.viz.shapes import Cylinder, Sphere
from pydy.viz.scene import Scene
from pydy.viz.visualization_frame import VisualizationFrame

from simulate import *

# Create geometry
# ===============

# Each link in the pendulum is visualized with a cylinder, and a sphere at its
# far end.
link = Cylinder(name='link', radius=0.5, length=l, color='red')
sphere = Sphere(name='sphere', radius=1.0)

# By default, Cylinders are drawn so that their center is at the origin of the
# VisualizationFrame, and their axis is the y axis of the VisualizationFrame.
# We want the end of the Cylinder to be at the origin of the
# VisualizationFrame, and we want the Cylinder's axis to be aligned with the x
# axis of the VisualizationFrame. For these reasons, we must use the
# 'orientnew' and 'locatenew' methods to create new frames/points.
linkP_frame = A.orientnew('frameP', 'Axis', [0.5 * pi, N.z])
linkP_origin = O.locatenew('originP', 0.5 * l * A.x)
linkP_viz_frame = VisualizationFrame('linkP', linkP_frame, linkP_origin, link)

linkR_frame = B.orientnew('frameR', 'Axis', [0.5 * pi, N.z])
linkR_origin = P.locatenew('originP', 0.5 * l * B.x)
linkR_viz_frame = VisualizationFrame('linkR', linkR_frame, linkR_origin, link)

sphereP_viz_frame = VisualizationFrame('sphereP', N, P, sphere)
sphereR_viz_frame = VisualizationFrame('sphereR', N, R, sphere)

# Construct the scene
# ===================

# We want gravity to be directed downwards in the visualization. Gravity is in
# the -x direction. By default, the visualization uses the xz plane as the
# ground plane. Thus, gravity is contained in the ground plane. However, we
# want gravity to point in the -y direction in the visualization. To achieve
# this, we create a world frame that is rotated +90 degrees about the N frame's
# z direction.
world_frame = N.orientnew('world', 'Axis', [0.5 * pi, N.z])
scene = Scene(world_frame, O,
        linkP_viz_frame, linkR_viz_frame, sphereP_viz_frame, sphereR_viz_frame)

# Create the visualization
# ========================

scene.generate_visualization_json_system(sys)

if __name__ == "__main__":
    try: #If called from inside notebook,
        scene.display_ipython()
    except:#If called from interpreter
        scene.display()

```


# Cloud evolution

## Evolution equations

The evolution of the cloud (boson mass $m_b = \hbar /(\mu c^2)$) is described within the quasiadiabatic approximation by a system of coupled differential equations (see e.g. [arXiv:1411.0686](http://stacks.iop.org/0264-9381/32/i=13/a=134001?key=crossref.8a4e2e36ca36fb28e1d0a6868573e646)).
These determine the rate of change of the cloud's mass ($\dot{M}_c= \dot{E}_c/c^2$) and angular momentum ($\dot{J}_c$) in terms of the power ($P_{gw}$) and angular momentum ($\dot{J}_{gw} = P_{gw}/\omega_R$) radiated in GWs  and the black-hole parameters ($M, J$), which is possibly affected by accretion ($\dot{M}_{\rm Acc}, \dot{J}_{\rm Acc}$).

\begin{align}
\dot{M}_c^{\rm (sr)} &= 2 M_c \omega_I\\
\dot{M} &= - \dot{M}_c^{\rm (sr)} + \dot{M}_{\rm Acc}\\
\dot{M} + \dot{M}_c &= - P_{gw}/c^2 + \dot{M}_{\rm Acc} \\
\dot{J} &= - m c^2 \dot{M}_c^{\rm (sr)} / \omega_R + \dot{J}_{\rm Acc}\\
\dot{J} + \dot{J}_c &= - m P_{gw}/\omega_R + \dot{J}_{\rm Acc}\, ,
\end{align}

where $\dot{M}_c^{\rm (sr)}$ is the change in the cloud mass due to superradiance only ($\dot{M}_c = \dot{M}_c^{\rm (sr)} - P_{gw}/c^2$), and $\omega = \omega_R + i \omega_I$ is the boson field frequency. In the case of no accretion, this simplifies to

\begin{align}
\dot{M} &= - 2 M_c \omega_I\\
\dot{M} + \dot{M}_c &= - P_{gw}/c^2 \\
\dot{J} &=- 2 m c^2 M_c \omega_I / \omega_R \\
\dot{J} + \dot{J}_c &= - m P_{gw}/\omega_R \, .
\end{align}

we can solve these equations numerically, starting from some initial parameters $(M_0, J_0, M_{c,0}, J_{c,0})$.

## Numerical setup

Set up the above system of DE's in a way ammenable to numerical treatment.

### No accretion

Define dimensionless $(x, y, j^x, j^y, {\cal P}, w, \tau)$ quantities such that
\begin{align}
M &= \alpha x\\
M_c &= \alpha y \\
J &= \beta j^x\\
J_c &= \beta j^y\\
P_{gw} &= \gamma {\cal P}\\
\omega &= w \mu\\
t &= \epsilon \tau\, .
\end{align}
We will make sure these are dimensionless by defining the dimensionful factors
\begin{align}
\alpha &= M_0 \\
\beta &= M_0 c^2 \mu^{-1} \\
\gamma &= M_0 c^2 \mu \\
\epsilon &= \mu^{-1}\, ,
\end{align}
and letting $\mu = \hbar/(m_b c^2)$ be the Compton frequency associated with the boson rest mass, $m_b$.
We will also let $M_0$ be the *initial* black-hole mass.
Note that then we have:
\begin{align}
a &= \frac{c j_x \epsilon}{x} \\
\chi &= \frac{j_x}{x^2} \frac{\epsilon}{T_0}\, ,
\end{align}
where we have defined the horizon crossing time $T_0 = G M_0/c^3$.
Also note that the SR condition is saturated when
\begin{equation}
\left( \frac{2}{m} \frac{T_0}{\epsilon}\right)^2 = \frac{j_x}{x^2\left(mx-j_x\right)}
\end{equation}

With the above notation, the BHB evolution equations become:
\begin{align}
x' &= -2 w_I y\\
x' + y' &= -{\cal P}\\
(j^x)' &= -2 m w_I w_R^{-1} y\\
(j^x)' + (j^y)' &= -m w_R^{-1} {\cal P}
\end{align}
for $m$ the boson magnetic quantum number ($m=1$ for dominant scalar level), and where the prime denotes ${\rm d}/{\rm d}\tau$.
We may turn these into finite-difference equations to obtain:
\begin{align}
x_{i+1} &= x_i -2 w^I_i y_i \delta \tau \\
y_{i+1} &= y_i + 2 w^I_i y_i \delta\tau -{\cal P}_i \delta \tau\\
j^x_{i+1} &= j^x_i -2 m (w^I_i/ w^R_i) y_i \delta \tau\\
j^y_{i+1} &= j^y_i + m \left( 2 w^I_i y_i -  {\cal P}_i\right)\delta \tau / w^R_i
\end{align}
These can be evolved from $(x_0, y_0, j^x_0, j^y_0)$ until the SR spin threshold is reached.
The final state can then be converted into physical units using the scalings defined above.

### Adding back accretion

In the presence of accretion, we may define the dimensioless accretion mass and angular momentum rates:

\begin{align}
{\cal M}_a ' &\equiv c^2 \gamma^{-1} \dot{M}_{\rm Acc} = (M_0 \mu)^{-1} \, \dot{M}_{\rm Acc} \\
{\cal J}_a ' &\equiv\, \epsilon \beta^{-1} \dot{J}_{\rm Acc} = (M_0 c^2)^{-1} \dot{J}_{\rm Acc} \, ,
\end{align}

so that the evolution equations become:

\begin{align}
x' &= -2 w_I y + {\cal M}_a '\\
x' + y' &= -{\cal P} + {\cal M}_a '\\
(j^x)' &= -2 m w_I w_R^{-1} y + {\cal J}_a' \\
(j^x)' + (j^y)' &= -m w_R^{-1} {\cal P} + {\cal J}_a' \, .
\end{align}

The corresponding finite-difference equations are:
\begin{align}
x_{i+1} &= x_i -2 w^I_i y_i \delta \tau + {\cal M}_a' \delta\tau  \\
y_{i+1} &= y_i + 2 w^I_i y_i \delta\tau -{\cal P}_i \delta \tau \\
j^x_{i+1} &= j^x_i -2 m (w^I_i/ w^R_i) y_i \delta \tau  + {\cal J}_a' \delta\tau\\
j^y_{i+1} &= j^y_i + m \left( 2 w^I_i y_i -  {\cal P}_i\right)\delta \tau / w^R_i \, ,
\end{align}

assuming that the accretion rate is constant.
Note that accretion only affects the BH quantities ($x, j^x$) by construction, because we assumed no direct interaction between the cloud and the accretion disk.

# Evolving forward

```python
%pylab inline
import gwaxion
```

    Populating the interactive namespace from numpy and matplotlib

## Final state

The `gwaxion` package can use the above equations (ignoring cloud angular momentum and GW power) to automatically obtain the final (post-superradiance) properities of a given black-hole--boson (BHB) system. Simply make sure this feature is turned on through the `evolve` argument when creating a `BosonCloud` object.

Here's an example for the $\ell=m=1$ cloud from pre-superradiance BH with $60\, M_\odot$ and $\chi=0.7$, and a boson such that $\alpha=0.176$.

```python
c = gwaxion.BosonCloud.from_parameters(1, 1, 0, evolve=True, m_bh=60,
                                       chi_bh=0.7, alpha=0.176)
```

Print some post-superradiance parameters, which will be internally computed using the above DEs.

```python
l = [
    "Final BH mass: %.2f Msun" % c.bhb_final.bh.mass_msun,
    "Final BH spin: %.3f" % c.bhb_final.bh.chi,
    "Final BH J: %.2e" % c.bhb_final.bh.angular_momentum,
    "Final alpha: %.3f" % c.bhb_final.alpha,
    "Final cloud mass: %.2f Msun" % c.mass_msun
]
print('\n'.join(l))
```

    Final BH mass: 58.89 Msun
    Final BH spin: 0.617
    Final BH J: 1.89e+45
    Final alpha: 0.173
    Final cloud mass: 1.11 Msun

If the DE evolution is turned off with `evolve=False`, then the code approximates the final BH parameters using Eqs. (25) and (26) in [arXiv:1706.06311](https://arxiv.org/abs/1706.06311), with constant $\alpha$ corresponding to the initial BH parameters.

The result is slightly different:

```python
c_approx = gwaxion.BosonCloud.from_parameters(1, 1, 0, evolve=False, m_bh=60,
                                              chi_bh=0.7, alpha=0.176)
```

```python
l = [
    "Final BH mass: %.2f Msun" % c_approx.bhb_final.bh.mass_msun,
    "Final BH spin: %.3f" % c_approx.bhb_final.bh.chi,
    "Final BH J: %.2e" % c_approx.bhb_final.bh.angular_momentum,
    "Final alpha: %.3f" % c_approx.bhb_final.alpha,
    "Final cloud mass: %.2f Msun" % c_approx.mass_msun
]
print('\n'.join(l))
```

    Final BH mass: 59.21 Msun
    Final BH spin: 0.624
    Final BH J: 1.93e+45
    Final alpha: 0.174
    Final cloud mass: 0.79 Msun

## Evolution

We can study the evolution of the system during the superrandiance process by looking at the properties of the BHB system over time. To do this with `gwaxion` we need to call the DE solver directly.

We can start from the `BosonCloud` object we defined above, and call the internal method `_evolve_instability()`

```python
bhb_final, (xs, jxs, ys, inv_wRs, wIs, sr_conds, times)  = c._evolve_instability()
```

The `bhb_final` object contains the final state of the evolution, and should give the same results as above, e.g.

```python
l = [
    "Final BH mass: %.2f Msun" % bhb_final.bh.mass_msun,
    "Final BH spin: %.3f" % bhb_final.bh.chi,
    "Final BH J: %.2e" % bhb_final.bh.angular_momentum,
    "Final alpha: %.3f" % bhb_final.alpha,
]
print('\n'.join(l))
```

    Final BH mass: 58.89 Msun
    Final BH spin: 0.617
    Final BH J: 1.89e+45
    Final alpha: 0.173

The other outputs contain the quantities computed at each step of the DE evolution: $x$, $j^x$, $y$, $w_R^{-1}$, $w_I$, defined [above](#No-accretion); as well as the "SR factor", which vanishes when SR stops; and time.

For example, as time goes by, the superradiance quickly saturates until it stops:

```python
scatter(times, sr_conds, marker='.')
ylabel('SR factor');
xlabel('Time (s)');
```

As this happens, the cloud grows in mass at the expense of the BH:

```python
plot(times, ys/xs)
axvline(6E9, ls='--', c='k')
axvline(6E9+1500*c.number_growth_time, ls='--', c='k')
xlabel('Time (s)');
ylabel(r'$M_c/M_{\rm BH}$');
```

Note that the timesteps are not equally separated, since the solver adapts the interval according to the evolution of the system:

```python
plot(times)
xlabel('Steps');
ylabel('Time (s)');
```

```python

```


# Supervised Learning

The supervised learning is one type of machine learning, which the main goal is to learn a model from **labeled** training data that allows us to make predictions about unseen or future date. In this case, the term "supervised" refers to a set of training exemples (i.e., data inputs), where the desired output signals (i.e., labels) are already known.

Naming convention:
* Features = predictor variables = independent variables
* Target variable = dependend variable = response variable

Functions that will be used bellow:

```python
from sklearn import datasets
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from mlxtend.plotting import scatterplotmatrix
```

## 1. Classification for Predicting Class Labels

Classification is a subcategory of supervised learning where the goal is to predict the categorical class labels of new instances, based on past observations. Those class labels are **discrete** , unordered values that can be understood as the group memberships of the instances.

As an example of a supervised learning algorithm, we will use the **k-nearest neighbor (KNN)** classifier, which is a typical **lazy learner**. This means that it doesn't learn a discriminative function from the training data but memorized the training dataset instead.

**KNN** belongs to a subcategory of **nonparametric** models that are described as **instanced-based learning**. Models based on instanced-based learning are characterized by memorizing the training dataset, and lazy learning is a particular case of instanced-based learning that is associated with no cost during the learning process. The **KNN** algorithm itself is relatively straightforward and can be summarized by the following steps:
* 1. Choose the number of *k* and a distance metric.
* 2. Find the *k*-nearest neighbors of the data record that we want to classify.
* 3. Assign the class label by majority vote.

Note: **Nonparametric** models of machine learning algorithms are models that can't be characterized by a fixed set of parameters, and the number of parameters grows with the training data. In contrast, for **parametric** models, we estimate parameters from the training dataset to learn a function that can classify new data points without requiring the original training dataset anymore.

### Exploratory Data Analysis

```python
iris = datasets.load_iris() #Load the data set.
print(iris.DESCR[19:-2300]) # Printing the dataset description
```

    Iris plants dataset
    --------------------

    **Data Set Characteristics:**

        :Number of Instances: 150 (50 in each of three classes)
        :Number of Attributes: 4 numeric, predictive attributes and the class
        :Attribute Information:
            - sepal length in cm
            - sepal width in cm
            - petal length in cm
            - petal width in cm
            - class:
                    - Iris-Setosa
                    - Iris-Versicolour
                    - Iris-Virginica

To confirm that the dataset was loaded successfully, we can display the first five lines of the dataset (as a pandas dataframe)

```python
X = iris.data
y = iris.target
df = pd.DataFrame(X,columns=iris.feature_names)
print(df.head())
```

       sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
    0                5.1               3.5                1.4               0.2
    1                4.9               3.0                1.4               0.2
    2                4.7               3.2                1.3               0.2
    3                4.6               3.1                1.5               0.2
    4                5.0               3.6                1.4               0.2

First, we will create a scatterplot matrix with multiple categories that allows us to visualize the pair-wise correlations between the different features in this dataset in one place, providing us a useful graphical summary of the relationships in a dataset.

```python
fig, axes = scatterplotmatrix(X[y==0], figsize=(10, 8), alpha=0.5)
fig, axes = scatterplotmatrix(X[y==1], fig_axes=(fig, axes), alpha=0.5)
fig, axes = scatterplotmatrix(X[y==2], fig_axes=(fig, axes), alpha=0.5, names=df.columns)

plt.tight_layout()
plt.show()
```

Looking at the graphic, it's possible to see that there is a high correlation between the petal length with the petal width. Thus,  we will assign only these two features for the matrix, X.

```python
X = iris.data[:, [2, 3]]
```

To evaluate how well a trained model performs on unseen data, we will further split the dataset into separate training and test datasets. Using the *train_test_split* function from scikit-learn's *model_selection* module, we randomly split the X and Y arrays into 30 percent test data (45 examples) and 70 percent training data (105 examples).

```python
from sklearn.model_selection import train_test_split
```

```python
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)
```

For an optimal performance we will standardize the features of the data using the *StandardScaler* class from scikit-learn's *preprocessing* module.

```python
from sklearn.preprocessing import StandardScaler
```

```python
sc = StandardScaler()
sc.fit(X_train)
X_train_std = sc.transform(X_train)
X_test_std = sc.transform(X_test)
```

By executing the following code, we will now implement a KNN model in scikit-learn using a Euclidian distance metric.

```python
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier(n_neighbors=6, p=2, metric='minkowski')
knn.fit(X_train_std, y_train)
```

    KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
                         metric_params=None, n_jobs=None, n_neighbors=6, p=2,
                         weights='uniform')

To visualize the decision boundaries for two-dimensional datasets

```python
from matplotlib.colors import ListedColormap
def plot_decision_regions(X, y, classifier, test_idx=None,
 resolution=0.02):
    # setup marker generator and color map
    markers = ('s', 'x', 'o', '^', 'v')
    colors = ('red', 'blue', 'lightgreen', 'gray', 'cyan')
    cmap = ListedColormap(colors[:len(np.unique(y))])

    # plot the decision surface
    x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx1, xx2 = np.meshgrid(np.arange(x1_min, x1_max, resolution),
                           np.arange(x2_min, x2_max, resolution))
    Z = classifier.predict(np.array([xx1.ravel(), xx2.ravel()]).T)
    Z = Z.reshape(xx1.shape)
    plt.contourf(xx1, xx2, Z, alpha=0.3, cmap=cmap)
    plt.xlim(xx1.min(), xx1.max())
    plt.ylim(xx2.min(), xx2.max())

    for idx, cl in enumerate(np.unique(y)):
        plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1], alpha=0.8, c=colors[idx],
                    marker=markers[idx], label=cl, edgecolor='black')
    # highlight test examples
    if test_idx:
        # plot all examples
        X_test, y_test = X[test_idx, :], y[test_idx]
        plt.scatter(X_test[:, 0], X_test[:, 1], c='', edgecolor='black', alpha=1.0,
                    linewidth=1, marker='o',s=100, label='test set')
```

To specify the indices of the examples that we want to mark on the
resulting plots.

```python
X_combined_std = np.vstack((X_train_std, X_test_std))
y_combined = np.hstack((y_train, y_test))
```

```python
plot_decision_regions(X_combined_std, y_combined, classifier=knn, test_idx=range(105,150))
plt.xlabel('petal length [standardized]')
plt.ylabel('petal width [standardized]')
plt.legend(loc='upper left')
plt.tight_layout()
plt.show()
```

### Measuring model performance

Now, we need to figure out how to measure the model performance. In classification problems, accuracy is a commonly-used metric. The accuracy of a classifier is defined as the number of correct predictions divided by the total number of data points.

We can compute the accuracy on both data used to fit the classifier and the test set but, for the data used to fit the classifier, the perfomance will not be indicative of how well it can generalize to unseen data.

```python
y_pred = knn.predict(X_test_std)
acc_train=knn.score(X_train_std,y_train).round(2)
acc_test=knn.score(X_test_std, y_test).round(2)
print("The accuracy for the train data is {} and for the test data is {}.".format(acc_train,acc_test))
```

    The accuracy for the train data is 0.98 and for the test data is 0.91.

To know *k* boundary to our date we can plot a model complexity curve, that can indicate us the best perfomance on the test.

```python
def plot_graph(X_train,X_test,y_train,y_test):
    # Setup arrays to store train and test accuracies
    neighbors = np.arange(1, 9)
    train_accuracy = np.empty(len(neighbors))
    test_accuracy = np.empty(len(neighbors))
    for i,k in enumerate(neighbors):
        # Setup a k-NN Classifier with k neighbors: knn
        knn = KNeighborsClassifier(n_neighbors=k)

        # Fit the classifier to the training data
        knn.fit(X_train,y_train)

        #Compute accuracy on the training set
        train_accuracy[i] = knn.score(X_train_std, y_train)

        #Compute accuracy on the testing set
        test_accuracy[i] = knn.score(X_test_std, y_test)

    # Generate plot
    plt.title('k-NN: Varying Number of Neighbors')
    plt.plot(neighbors, test_accuracy, label = 'Testing Accuracy')
    plt.plot(neighbors, train_accuracy, label = 'Training Accuracy')
    plt.legend()
    plt.xlabel('Number of Neighbors')
    plt.ylabel('Accuracy')
    plt.show()
```

```python
plot_graph(X_train_std,X_test_std,y_train,y_test)
```

### Fine-Tuning

We evaluated the performance of the k-NN classifier based on its accuracy. However, accuracy is not always an informative metric and, several other performance metrics can be used to measure a model's relevance, such as precision, recall, and the **F1 score**.

We will dive more deeply into evaluating the performance of classifiers by computing a confusion matrix and generating a classification report. A confusion matrix is simply a square matrix that reports the counts of the **true positive (TP)**, **true negative (TN)**, **false positive (FP)**, and **false negative (FN)** predictions of a classifier.

The classification report consists of three rows and an additional support column.The support gives the number of samples of the true response that lie in that class. The precision, recall, and f1-score columns gave the respective metrics for that particular class.

```python
# Import necessary modules
from sklearn.metrics import classification_report
from sklearn.metrics import confusion_matrix

# Instantiate a k-NN classifier: knn
knn = KNeighborsClassifier(n_neighbors=6)

# Fit the classifier to the training data
knn.fit(X_train_std, y_train)

# Predict the labels of the test data: y_pred
y_pred = knn.predict(X_test_std)

#Plot the confusion matrix
confmat = confusion_matrix(y_test, y_pred)
def conf_plot(confmat):
    fig, ax = plt.subplots(figsize=(5, 5))
    ax.matshow(confmat, cmap=plt.cm.Blues, alpha=0.3)
    for i in range(confmat.shape[0]):
        for j in range(confmat.shape[1]):
            ax.text(x=j, y=i,s=confmat[i, j],va='center', ha='center')
    plt.xlabel('Predicted label')
    plt.ylabel('True label')
    plt.show()

conf_plot(confmat)

# Classification report
print(classification_report(y_test, y_pred))
```

By the graph, we can see that the model correctly classified all examples that belong to class 0 (Iris-Setosa) and made incorrect classification for three cases that belong to class 1 (Iris-Versicolour) and one wrong classification that belongs to class 2 (Iris-Virginica).

## 2. Regression

A second type of supervised learning is the prediction of **continuous** outcomes, which is also called regression analysis. In regression analysis, we are given a number of predictor (**explanatory**) variables and a continuous response variable (**outcome**), and we try to find a relationship between those variables that allows us to predict an outcome.

### Linear Regression

The goal of linear regression is to model the relationship between one or multiple features and a continuous target variable.

In a simple (**univariate**) linear regression the goal is to model the relationship between a single feature (**explanatory variable**, *x*) and a continuous-valued **target** (**response variable**,*y*). The equation of a linear model with one explanatory variable is defined as follows:

\begin{equation}
    y = \beta_{0} + \beta_{1}x_{1} + \epsilon
\end{equation}

where,

- $x_{1}$ = explanatory variable
- y = response variable
- $\beta_{0}$ = *y* axis intercept
- $\beta_{1}$ = associated parameter
- $\epsilon$ = residual

Based on this equation, linear regression can be understood as fiding the best-fitting straight line (i.e., $\beta_{0}$ and $\beta_{1}$ values) throught the training examples.

### Multiple linear regression

Of course, we can also generalize the linear regression model to **multiple explanatory variables**; this process is called **multiple linear regression**:

\begin{equation}
    y = \beta_{0} + \beta_{1}x_{1} + \beta_{2}x_{2} + ... + \beta_{p}x_{p} + \epsilon
\end{equation}

where,

- $x_{1}$...$x_{p}$ = explanatory variables
- y = response variable
- $\beta_{0}$ = *y* axis intercept
- $\beta_{1}$...$\beta_{p}$ = associated parameters
- $\epsilon$ = residual erros

### Exploratory Data Analysis - The Housing dataset

```python
boston = datasets.load_boston()
print(boston['DESCR'][21:-1078])
```

    Boston house prices dataset
    ---------------------------

    **Data Set Characteristics:**

        :Number of Instances: 506

        :Number of Attributes: 13 numeric/categorical predictive. Median Value (attribute 14) is usually the target.

        :Attribute Information (in order):
            - CRIM     per capita crime rate by town
            - ZN       proportion of residential land zoned for lots over 25,000 sq.ft.
            - INDUS    proportion of non-retail business acres per town
            - CHAS     Charles River dummy variable (= 1 if tract bounds river; 0 otherwise)
            - NOX      nitric oxides concentration (parts per 10 million)
            - RM       average number of rooms per dwelling
            - AGE      proportion of owner-occupied units built prior to 1940
            - DIS      weighted distances to five Boston employment centres
            - RAD      index of accessibility to radial highways
            - TAX      full-value property-tax rate per $10,000
            - PTRATIO  pupil-teacher ratio by town
            - B        1000(Bk - 0.63)^2 where Bk is the proportion of blacks by town
            - LSTAT    % lower status of the population
            - MEDV     Median value of owner-occupied homes in $1000's

        :Missing Attribute Values: None

To confirm that the dataset was loaded successfully, we can display the first five lines of the dataset (as a pandas dataframe)

```python
df = pd.DataFrame(boston.data,columns=boston.feature_names)
df = pd.concat([df,pd.DataFrame({'MEDV':boston.target})],axis=1)
df.head()
```

      CRIM
      ZN
      INDUS
      CHAS
      NOX
      RM
      AGE
      DIS
      RAD
      TAX
      PTRATIO
      B
      LSTAT
      MEDV

      0
      0.00632
      18.0
      2.31
      0.0
      0.538
      6.575
      65.2
      4.0900
      1.0
      296.0
      15.3
      396.90
      4.98
      24.0

      1
      0.02731
      0.0
      7.07
      0.0
      0.469
      6.421
      78.9
      4.9671
      2.0
      242.0
      17.8
      396.90
      9.14
      21.6

      2
      0.02729
      0.0
      7.07
      0.0
      0.469
      7.185
      61.1
      4.9671
      2.0
      242.0
      17.8
      392.83
      4.03
      34.7

      3
      0.03237
      0.0
      2.18
      0.0
      0.458
      6.998
      45.8
      6.0622
      3.0
      222.0
      18.7
      394.63
      2.94
      33.4

      4
      0.06905
      0.0
      2.18
      0.0
      0.458
      7.147
      54.2
      6.0622
      3.0
      222.0
      18.7
      396.90
      5.33
      36.2

First, we will create a scatterplot matrix that allows us to visualize the pair-wise correlations between the different features in this dataset in one place, providing us a useful graphical summary of the relationships in a dataset.

```python
cols = ['LSTAT', 'INDUS', 'NOX', 'RM', 'MEDV']
scatterplotmatrix(df[cols].values,figsize=(20, 16),names=cols, alpha=0.5)
plt.tight_layout()
plt.show()
```

We can now quickly eyeball how the data is distributed and whether it contains outliers. For example, we can see that there is a linear relationship between RM and house prices, MEDV (the fifth column of the fourth row). Furthermore, we can see in the histogram—the lower-right subplot in the scatterplot matrix—that the MEDV variable seems to be normally distributed but contains several outliers.

Now, we can look at the relationships using a correlation matrix, which is a square matrix that contains the **Pearson product-moment** correlation coefficient (often abbreviated as **Pearson's r**), which measures the linear dependence between pairs of features.
The correlation coefficients are in the range –1 to 1. Two features have a perfect positive correlation if r = 1,
no correlation if r = 0, and a perfect negative correlation if r = –1.

```python
_ = plt.figure(figsize=(15,10))
matrix = np.triu(df.corr())
sns.heatmap(df.corr(),annot=True, mask=matrix,linewidths=.5)
plt.show()
```

For the linear regression implementation in scikit-learn it is better to use unstandardized variables, since it does not use (S)GD-based optimization, so we can skip the standardization step.

Based on the previous equations it was defined that the linear regression can be understood as obtaining the **best-fitting straight line** through the training data. The best-fitting straight line is the line that minimizes the residual errors ($\epsilon$).

In the following we will use the ordinary least squares (**OLS**) method (sometimes also called linear least squares) to estimate the parameters of the linear regression line that minimizes the sum of the squared vertical distances (residuals or errors) to the training examples.

```python
# Import LinearRegression
from sklearn.linear_model import LinearRegression
X = df[['RM']].values
y = df['MEDV'].values

# Create the regressor: reg
reg = LinearRegression()

# Fit the model to the data
reg.fit(X,y)

# Print Slope and Intercept values
print('Slope: %.3f' % reg.coef_[0])
print('Intercept: %.3f' % reg.intercept_)

# Create the prediction space
prediction_space = np.linspace(min(X), max(X)).reshape(-1,1)

# Compute predictions over the prediction space: y_pred
y_pred = reg.predict(prediction_space)

# Plot regression line
plt.plot(prediction_space, y_pred, color='black', linewidth=3)
plt.scatter(X, y, c='steelblue', edgecolor='white', s=70)
plt.xlabel('Average number of rooms [RM]')
plt.ylabel('Price in $1000s [MEDV]')
plt.show()
```

### Evaluating the performance of linear regression models

It is crucial to test the model on data that it hasn't seen during training to obtain a more unbiased estimate of its
generalization performance. On this way, we will split the dataset into separate training and test datasets, will be used the former to fit the model and the latter to evaluate its performance on unseen data to estimate the generalization performance.

Here, we will proceed using all variables in the dataset (i.e., **multiple linear regression**)

```python
# Import necessary modules
from sklearn.metrics import mean_squared_error

X = df.iloc[:, :-1].values
y = df['MEDV'].values

#Split the data
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size = .3, random_state=42)

# Create the regressor: reg_all
reg_all = LinearRegression()

# Fit the regressor to the training data
reg_all.fit(X_train,y_train)

# Predict on the test and the train data: y_test_pred, y_train_pred
y_train_pred = reg_all.predict(X_train)
y_test_pred = reg_all.predict(X_test)

# Compute and print R^2 and RMSE
print("R^2: {}".format(reg_all.score(X_test, y_test).round(2)))
rmse = np.sqrt(mean_squared_error(y_test,y_test_pred)).round(2)
print("Root Mean Squared Error: {}".format(rmse))
```

    R^2: 0.71
    Root Mean Squared Error: 4.64

Since our model uses multiple explanatory variables, we can't visualize the linear regression line (or hyperplane, to be precise) in a two-dimensional plot, but we can plot the residuals (the differences or vertical distances between the actual and
predicted values) versus the predicted values to diagnose our regression model.

```python
plt.scatter(y_train_pred, y_train_pred - y_train,c='steelblue',
            marker='o', edgecolor='white', label='Training data')
plt.scatter(y_test_pred, y_test_pred - y_test,c='limegreen',
            marker='s', edgecolor='white', label='Test data')
plt.xlabel('Predicted values')
plt.ylabel('Residuals')
plt.legend(loc='upper left')
plt.hlines(y=0, xmin=-10, xmax=50, color='black', lw=2)
plt.xlim([-10, 50])
plt.show()
```

For a good regression model, we would expect the errors to be randomly distributed and the residuals to be randomly scattered around the centerline. If we see patterns in a residual plot, it means that our model is unable to capture some explanatory
information, which has leaked into the residuals, as you can slightly see in our previous residual plot.

### Cross-Validation

If you are computing the RMSE on your test, the RMSE returned is dependent on the way that you split up the data. The data points in the test set may have some peculiarities that mean the RMSE computed on it is not representative of the model's ability to generalize to unseen data.
To combat this dependence on what is essenciatially an abitrary split, we use a technique called **k-fold cross-validation**.

In k-fold cross-validation, we randomly split the training dataset into k folds without replacement, where k – 1 folds are used for the model training, and one fold is used for performance evaluation. This procedure is repeated k times so that we obtain k
models and performance estimates.

Since k-fold cross-validation is a resampling technique without replacement, the advantage of this approach is that each example will be used for training and validation (as part of a test fold) exactly once, which yields a lower-variance estimate of the model performance than the holdout method.

Read more and different applications in [Scikit-Learn Cross-Validation User's Guide](https://scikit-learn.org/stable/modules/cross_validation.html)

```python
# Import the necessary modules
from sklearn.model_selection import cross_val_score

X = df[['RM']].values
y = df['MEDV'].values

# Create the regressor: reg
reg = LinearRegression()

# Compute 5-fold cross-validation scores: cv_scores
cv_scores = cross_val_score(reg, X, y, cv=5)

# Print the 5-fold cross-validation scores
print(cv_scores)
print("Average 5-Fold CV Score: {}".format(np.mean(cv_scores).round(2)))
```

    [ 0.70708692  0.63476138  0.50385441 -0.21594318 -1.77736913]
    Average 5-Fold CV Score: -0.03

#### Hold-out set

If we want to report how well a model can be expected to perform on a dataset that it has never seen before, given a scoring function of choice.
- Using ALL data for cross-validation is not ideal
- Split data into training and hold-out set at the beginning
- Perform grid search cross-validation on training set
- Evalute the model using the test set to see how well the model can be expected to perform on a dataset that it has never seen before.

### Regularized Regression

Overfitting is a common problem in machine learning, where a model performs well on training data but does not generalize well to unseen data (test data). If a model suffers from overfitting, we also say that the model has a high variance, which can
be caused by having too many parameters, leading to a model that is too complex given the underlying data. Similarly, our model can also suffer from underfitting (high bias), which means that our model is not complex enough to capture the pattern in the training data well and therefore also suffers from low performance on unseen data.

One way of finding a good bias-variance tradeoff is to tune the complexity of the model via regularization. Regularization is a very useful method for handling collinearity (high correlation among features), filtering out noise from data, and eventually preventing overfitting.

The concept behind regularization is to introduce additional information (bias) to penalize extreme parameter (weight) values.  The most popular approaches to regularized linear regression are the so-called **Ridge Regression, least absolute shrinkage and
selection operator (LASSO)**.

**Ridge Regression** is an L2 penalized model where we simply add the squared sum of the weights to our least-squares cost function:

\begin{equation}
    Loss function = OLS loss function + \alpha * \sum_{i=1}^{n}a_{i}
\end{equation}

**LASSO** performs regularization by adding to the loss function a penalty term of the absolute value of each coefficient multiplied by some alpha. This is also known as L1 regularization because the regularization term is the L1 norm of the coefficients. Here, the penalty for **LASSO** is defined as the sum of the absolute magnitudes of he model weights, as follows:

\begin{equation}
    Loss function = OLS loss function + \alpha * \sum_{i=1}^{n}\left |a_{i} \right |
\end{equation}

where,
- $\alpha$ = Parameter we need to choose that controls model complexity ($\alpha$ = 0: We get back OLS, which can lead to overt - very high $\alpha$: Can lead to undertting)

An important advantage of LASSO is that it can be used to select important features of a dataset once it shrinks the coefcients ofless important features to exactly 0.

In both cases picking alpha is similar to picking k in k-NN.

```python
X = df.iloc[:, :-1].values
y = df['MEDV'].values
```

```python
# Import necessary modules
from sklearn.linear_model import Ridge
from sklearn.model_selection import cross_val_score

#Defining the plot function
def display_plot(alpha_space,cv_scores, cv_scores_std):
    fig = plt.figure()
    ax = fig.add_subplot(1,1,1)
    ax.plot(alpha_space, cv_scores)

    std_error = cv_scores_std / np.sqrt(10)

    ax.fill_between(alpha_space, cv_scores + std_error, cv_scores - std_error, alpha=0.2)
    ax.set_ylabel('CV Score +/- Std Error')
    ax.set_xlabel('Alpha')
    ax.axhline(np.max(cv_scores), linestyle='--', color='.5')
    ax.set_xlim([alpha_space[0], alpha_space[-1]])
    ax.set_xscale('log')
    plt.show()

# Setup the array of alphas and lists to store scores
alpha_space = np.logspace(-4, 0, 50)
ridge_scores = []
ridge_scores_std = []

# Create a ridge regressor: ridge
ridge = Ridge(normalize=True)

# Compute scores over range of alphas
for alpha in alpha_space:
    # Specify the alpha value to use: ridge.alpha
    ridge.alpha = alpha
    # Perform 10-fold CV: ridge_cv_scores
    ridge_cv_scores = cross_val_score(ridge,X,y,cv=10)
    # Append the mean of ridge_cv_scores to ridge_scores
    ridge_scores.append(np.mean(ridge_cv_scores))
    # Append the std of ridge_cv_scores to ridge_scores_std
    ridge_scores_std.append(np.std(ridge_cv_scores))

# Display the plot
display_plot(alpha_space,ridge_scores, ridge_scores_std)

# Print the alpha with the max CV score
index = ridge_scores.index(np.max(ridge_scores))
print('The alpha with the max CV score is {}'.format(round(alpha_space[index],2)))
```

Using LASSO to select the most important features of a dataset.

```python
# Import Lasso
from sklearn.linear_model import Lasso

# Instantiate a lasso regressor: lasso
lasso = Lasso(alpha=0.1, normalize=True)

# Fit the regressor to the data
lasso.fit(X, y)

# Compute the coefficients
lasso_coef = lasso.fit(X, y).coef_

# Plot the coefficients as a function of feature name
plt.plot(range(len(df.iloc[:, :-1].columns)), lasso_coef)
plt.xticks(range(len(df.iloc[:, :-1].columns)), df.iloc[:, :-1].columns.values, rotation=60)
_ = plt.ylabel('Coefficients')
plt.margins(0.02)
plt.show()
```

Now, we can see directly that the most important predictor for our target variable, housing price, is number of rooms!

### Hyperparameter tuning

In machine learning, we have two types of parameters: those that are learned from the training data, for example, the weights in logistic regression, and the parameters of a learning algorithm that are optimized separately. The latter are the
tuning parameters (or **hyperparameters**) of a model, for example, the regularization parameter in logistic regression or the depth parameter of a decision tree.

Wat a popular hyperparameter optimization technique called **grid search**, which can further help to improve the performance of a model by finding the optimal combination of hyperparameter values. The grid search approach is quite simple: it's a brute-force exhaustive search paradigm where we specify a list of values for different hyperparameters, and the computer evaluates the model performance for each combination to obtain the optimal combination of values from this set.

```python
# Import necessary modules
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV

# Setup the hyperparameter grid
c_space = np.logspace(-5, 8, 15)
param_grid = {'C': c_space}

# Instantiate a logistic regression classifier: logreg
logreg = LogisticRegression()

# Instantiate the GridSearchCV object: logreg_cv
logreg_cv = GridSearchCV(logreg, param_grid, cv=5)

# Fit it to the data
logreg_cv.fit(X,y)

# Print the tuned parameters and score
print("Tuned Logistic Regression Parameters: {}".format(logreg_cv.best_params_))
print("Best score is {}".format(logreg_cv.best_score_))

```

GridSearchCV can be computationally expensive, especially if you are searching over a large hyperparameter space and dealing with multiple hyperparameters. A solution to this is to use RandomizedSearchCV, in which not all hyperparameter values are tried out. Instead, a fixed number of hyperparameter settings is sampled from specified probability distributions.

```python
# Import necessary modules
from scipy.stats import randint
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import RandomizedSearchCV

# Setup the parameters and distributions to sample from: param_dist
param_dist = {"max_depth": [3, None],
              "max_features": randint(1, 9),
              "min_samples_leaf": randint(1, 9),
              "criterion": ["gini", "entropy"]}

# Instantiate a Decision Tree classifier: tree
tree = DecisionTreeClassifier()

# Instantiate the RandomizedSearchCV object: tree_cv
tree_cv = RandomizedSearchCV(tree, param_dist, cv=5)

# Fit it to the data
tree_cv.fit(X,y)

# Print the tuned parameters and score
print("Tuned Decision Tree Parameters: {}".format(tree_cv.best_params_))
print("Best score is {}".format(tree_cv.best_score_))
```

```python
# Import necessary modules
from sklearn.linear_model import ElasticNet
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import train_test_split

# Create train and test sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.4, random_state=42)

# Create the hyperparameter grid
l1_space = np.linspace(0, 1, 30)
param_grid = {'l1_ratio': l1_space}

# Instantiate the ElasticNet regressor: elastic_net
elastic_net = ElasticNet()

# Setup the GridSearchCV object: gm_cv
gm_cv = GridSearchCV(elastic_net, param_grid, cv=5)

# Fit it to the training data
gm_cv.fit(X_train, y_train)

# Predict on the test set and compute metrics
y_pred = gm_cv.predict(X_test)
r2 = gm_cv.score(X_test, y_test)
mse = mean_squared_error(y_test, y_pred)
print("Tuned ElasticNet l1 ratio: {}".format(gm_cv.best_params_))
print("Tuned ElasticNet R squared: {}".format(r2))
print("Tuned ElasticNet MSE: {}".format(mse))
```

## Preprocessing

### Standarzitazion
The performance of a model can improve if the features are scaled, but that this is not always the case.

```python
# Import the necessary modules
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

# Setup the pipeline steps: steps
steps = [('scaler', StandardScaler()),
        ('knn', KNeighborsClassifier())]

# Create the pipeline: pipeline
pipeline = Pipeline(steps)

# Create train and test sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.3, random_state=42)

# Fit the pipeline to the training set: knn_scaled
knn_scaled = pipeline.fit(X_train,y_train)

# Instantiate and fit a k-NN classifier to the unscaled data
knn_unscaled = KNeighborsClassifier().fit(X_train, y_train)

# Compute and print metrics
print('Accuracy with Scaling: {}'.format(knn_scaled.score(X_test, y_test)))
print('Accuracy without Scaling: {}'.format(knn_unscaled.score(X_test, y_test)))
```

### Bringing it all together I: Pipeline for classification

```python
# Setup the pipeline
steps = [('scaler', StandardScaler()),
         ('SVM', SVC())]

pipeline = Pipeline(steps)

# Specify the hyperparameter space
parameters = {'SVM__C':[1, 10, 100],
              'SVM__gamma':[0.1, 0.01]}

# Create train and test sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2, random_state=21)

# Instantiate the GridSearchCV object: cv
cv = GridSearchCV(pipeline, parameters, cv=3)

# Fit to the training set
cv.fit(X_train, y_train)

# Predict the labels of the test set: y_pred
y_pred = cv.predict(X_test)

# Compute and print metrics
print("Accuracy: {}".format(cv.score(X_test, y_test)))
print(classification_report(y_test, y_pred))
print("Tuned Model Parameters: {}".format(cv.best_params_))

```

### Bringing it all together II: Pipeline for regression

```python
# Setup the pipeline steps: steps
steps = [('imputation', Imputer(missing_values='NaN', strategy='mean', axis=0)),
         ('scaler', StandardScaler()),
         ('elasticnet', ElasticNet())]

# Create the pipeline: pipeline
pipeline = Pipeline(steps)

# Specify the hyperparameter space
parameters = {'elasticnet__l1_ratio':np.linspace(0,1,30)}

# Create train and test sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.4, random_state=42)

# Create the GridSearchCV object: gm_cv
gm_cv = GridSearchCV(pipeline, parameters)

# Fit to the training set
gm_cv.fit(X_train, y_train)

# Compute and print the metrics
r2 = gm_cv.score(X_test, y_test)
print("Tuned ElasticNet Alpha: {}".format(gm_cv.best_params_))
print("Tuned ElasticNet R squared: {}".format(r2))
```


```python
import numpy as np
import sympy as sp
import math

import hw10 as p1

import matplotlib.pyplot as plt

%matplotlib inline
```

# Homework 10

## Chinmai Raman

### 5/22/2016

1. Consider the coupled set of ODEs with initial conditions $u(0) = 1$, $v(0) = 0$:

   $$u'(t) = v(t)$$
   $$v'(t) = -u(t)$$

   Show that the solution $u(t) = \cos(t)$ and $v(t) = -\sin(t)$ satisfies these equations. Explain what $u(t)$ and $v(t)$ could correspond to in a physical system.

$u'(t) = -sin(t) = v(t), v'(t) = -cos(t) = -u(t)$

$cos(0) = 1$ and $-sin(0) = 0$

Therefore $u(t)=cos(t)$  and $v(t)=−sin(t)$ satisfy the coupled ODEs

In the below graphs, the red

## Euler

```python
p1.plot('euler', 1000)
```

```python
p1.plot('euler', 5000)
```

```python
p1.plot('euler', 15000)
```

The Euler method provides us with a first order approximation of -sin(x) and cos(x) given the initial values. An order of magnitude of 4 for the value of n seems to provide us with a qualitatively correct solution to the system of ODEs.

## Heun

```python
p1.plot('heun', 100)
```

```python
p1.plot('heun', 200)
```

```python
p1.plot('heun', 500)
```

Heun's method, a second-order method, provides us with a better approximation of the functions than Euler does. An order of magnitude of 2 for n provides us with an graph virtually indistinguishable from the true functions.

## Runge-Kutta (second order)

```python
p1.plot('rungekutta2', 1000)
```

```python
p1.plot('rungekutta2', 10000)
```

```python
p1.plot('rungekutta2', 50000)
```

The second order Runge-Kutta method proves worse than the Euler and Heun methods in this case. Even at relatively high values of n--whereas using other methods the graph would have already been indistinguishable from the true functions--Runge-Kutta2 displays an upward oscillatory trend that requires an order of magnitude of at least 4 in order to curtail. Although it also of second order like Heun, Runge-Kutta2 proves highly inefficient for these particular coupled ODEs.

## Runge-Kutta (fourth order)

```python
p1.plot('rungekutta4', 1000)
```

```python
p1.plot('rungekutta4', 10000)
```

```python
p1.plot('rungekutta4', 30000)
```

This fourth order method proves no more qualitatively efficient than its second order counterpart, as it still requires an order of magnitude of 4 in order to match the actual functions. It would seem that Runge-Kutta is poorly adapted to functions that display oscillatory behavior. Heun's method, although of second-order, is by far the most efficient in solving the coupled ODEs with a low number of timesteps.

```python

```


1. List following function in incresing order in term of its growth (Big O notation). Don't forget to put its Big O beside each function
 $n!$, $\log_2 n $, $n - n^3 + 7n^5$, $n^2$, $2^n$, $10$, $(\log_2{n})^2$, $\frac{1}{n}$, $(\frac{1}{3})^n$, $\sqrt{n}, n^{\frac{1}{3}}-\log_2{n}$, $n^{log_2{n}}$

**Answer:**

2. Solve the following recurrence exactly:
\begin{equation}
  T(n)=\begin{cases}
    8, & \text{if $n=1$}.\\
    3T(n-1), & \text{$n \ge 2$}.
  \end{cases}
\end{equation}

**Answer:**

3. Solve the following recurrence exactly:
\begin{equation}
  T(n)=\begin{cases}
    4, & \text{if $n=1$}.\\
    2T(\frac{n}{2}) + 3n + 2, & \text{$n \ge 2$}.
  \end{cases}
\end{equation}

**Answer:**

4. Solve the following recurrence exactly:
\begin{equation}
  T(n)=\begin{cases}
    4, & \text{if $n=1$}.\\
    3T(\frac{n}{2}) + n^2 - 2n + 1, & \text{$n \ge 2$}.
  \end{cases}
\end{equation}

**Answer:**

5. Compare the two sorting algorithm below based on its running time (using `timeit`) with input from $n = 2$ to $10000$ with increment of $10$. Plot the result using matplotlib plot function.

```
# -*- coding: utf-8 -*-
"""
Bubble Sort
Created on Wed Feb  3 10:15:52 2021
@author: isandjaja
"""
import random
""

A = [random.randrange(2,10000,10) for i in range(10)]

print(A)

def bubblesort(A):
    n = len(A)
    for i in range(n):
        for j in range(n-1):
            print("i,j,j+1", i,j,j+1)
            if A[j] > A[j+1]:
                A[j],A[j+1] = A[j+1], A[j] #Swap A[j]
            print(A)
    return A

print("Sorted A")
B = bubblesort(A)
print(B)
if __name__ == "__main__":
  from timeit import timeit
  print(timeit(bubblesort(A), number=5))
  print(timeit(bubblesort(A), number=5))
```

```
# -*- coding: utf-8 -*-
"""
MergeSort
Created on Fri Feb  5 10:43:30 2021
@author: isandjaja
"""
import random

A = [random.randrange(1,50,1) for i in range(10)]

print(A)

def merge(A,B):
    lenA = len(A)
    lenB = len(B)
    i = 0 # index for A
    j = 0 # index for B
    result = []
    print("Merge part (begin)",A,B)
    while(i<lenA) and (j<lenB):
        if A[i] <= B[j]:
            result.append(A[i])
            i += 1
        else:
            result.append(B[j])
            j += 1

    # Copy remaining elements to result
    while(i<lenA):
        result.append(A[i])
        i += 1
    while(j<lenB):
        result.append(B[j])
        j += 1
    print("Merge part (end)", result)
    return result

def mergesort(A):
    n = len(A)
    if n<=1:
        return A
    else:
        mid = n//2
        a = A[:mid]
        b = A[mid:]
        print("Divide part",a,b)
        return merge(mergesort(a),mergesort(b))

print("Sorted A")
B = mergesort(A)
print(B)
```

```

```


# Best responses

---

## Definition of a best response

[Video](https://youtu.be/cJUZEmfhdcA?list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

In a two player game $(A,B)\in{\mathbb{R}^{m\times n}}^2$ a mixed strategy $\sigma_r^*$  of the row player is a best response to a column players' strategy $\sigma_c$ iff:

$$
\sigma_r^*=\text{argmax}_{\sigma_r\in S_r}\sigma_rA\sigma_c^T.
$$

Similarly a mixed strategy $\sigma_c^*$  of the column player is a best response to a row players' strategy $\sigma_r$ iff:

$$
\sigma_c^*=\text{argmax}_{\sigma_c\in S_c}\sigma_rB\sigma_c^T.
$$

---

In other words: a best response strategy maximise the utility of a player given a known strategy of the other player.

## Best responses in the Prisoners Dilemma

Consider the Prisoners Dilemma:

$$
A = \begin{pmatrix}
3 & 0\\
5 & 1
\end{pmatrix}\qquad
B = \begin{pmatrix}
3 & 5\\
0 & 1
\end{pmatrix}
$$

We can easily identify the pure strategy best responses by underlying the corresponding utilities. For the row player, we will underline the best utility in each column:

$$
A = \begin{pmatrix}
3 & 0\\
\underline{5} & \underline{1}
\end{pmatrix}
$$

For the column player we underling the best utility in each row:

$$
B = \begin{pmatrix}
3 & \underline{5}\\
0 & \underline{1}
\end{pmatrix}
$$

We see that both players' best responses are their second strategy.

## Best responses in matching pennies

[Video](https://youtu.be/dLUWbKNxU44?list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

Consider matching pennies with the best responses underlined:

$$
A = \begin{pmatrix}
\underline{1} & -1\\
-1 & \underline{1}
\end{pmatrix}\qquad
B = \begin{pmatrix}
-1 & \underline{1}\\
\underline{1} & -1
\end{pmatrix}
$$

We see that the best response now depend on what the opponent does.

Let us consider the best responses against a mixed strategy (and apply the previous definition):

- Assume $\sigma_r=(x,1-x)$
- Assume $\sigma_c=(y,1-y)$

We have:

$$
A\sigma_c^T = \begin{pmatrix}
2y-1\\
1-2y
\end{pmatrix}\qquad
\sigma_rB = \begin{pmatrix}
1-2x & 2x-1
\end{pmatrix}
$$

```python
import sympy as sym
import numpy as np
sym.init_printing()

x, y = sym.symbols('x, y')
A = sym.Matrix([[1, -1], [-1, 1]])
B = - A
sigma_r = sym.Matrix([[x, 1-x]])
sigma_c = sym.Matrix([y, 1-y])
A * sigma_c, sigma_r * B
```

Those two vectors gives us the utilities to the row/column player when they play either of their pure strategies:

- $(A\sigma_c^T)_i$ is the utility of the row player when playing strategy $i$ against $\sigma_c=(y, 1-y)$
- $(\sigma_rB)_j$ is the utility of the column player when playing strategy $j$ against $\sigma_r=(x, 1-x)$

Let us plot these (using `matplotlib`):

```python
import matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
matplotlib.rc("savefig", dpi=100)  # Increase the quality of the images (not needed)

ys = [0, 1]
row_us = [[(A * sigma_c)[i].subs({y: val}) for val in ys] for i in range(2)]
plt.plot(ys, row_us[0], label="$(A\sigma_c^T)_1$")
plt.plot(ys, row_us[1], label="$(A\sigma_c^T)_2$")
plt.xlabel("$\sigma_c=(y, 1-y)$")
plt.title("Utility to player 1")
plt.legend();
```

```python
xs = [0, 1]
row_us = [[(sigma_r * B)[j].subs({x: val}) for val in xs] for j in range(2)]
plt.plot(ys, row_us[0], label="$(\sigma_rB)_1$")
plt.plot(ys, row_us[1], label="$(\sigma_rB)_2$")
plt.xlabel("$\sigma_r=(x, 1-x)$")
plt.title("Utility to column player")
plt.legend();
```

We see that the best responses to the mixed strategies are given as:

$$
\sigma_r^* =
\begin{cases}
(1, 0),&\text{ if } y > 1/2\\
(0, 1),&\text{ if } y < 1/2\\
\text{indifferent},&\text{ if } y = 1/2
\end{cases}
\qquad
\sigma_c^* =
\begin{cases}
(0, 1),&\text{ if } x > 1/2\\
(1, 0),&\text{ if } x < 1/2\\
\text{indifferent},&\text{ if } x = 1/2
\end{cases}
$$

In this particular case we see that for any given strategy, the opponents' best response is either a pure strategy or a mixed strategy in which case they are indifferent between the pure strategies.

For example:

- If $\sigma_c=(1/4, 3/4)$ ($y=1/4$) then the best response is $\sigma_r^*=(0,1)$
- If $\sigma_c=(1/2, 1/2)$ ($y=1/2$) then any mixed strategy is a best response **but** in fact both pure strategies would give the same utility (the lines intersect).

This observation generalises to our first theorem:

---

## Best response condition

[Video](https://youtu.be/UQWoNZBifs8?list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

In a two player game $(A,B)\in{\mathbb{R}^{m\times n}}^2$ a mixed strategy $\sigma_r^*$  of the row player is a best response to a column players' strategy $\sigma_c$ iff:

$${\sigma_r^*}_i > 0 \Rightarrow (A\sigma_c^T)_i = \max_{k}(A\sigma_c^T)_k\text{ for all }1\leq i\leq m$$

### Proof of best response condition

$(A\sigma_c^T)_i$ is the utility of the row player when they play their $i$th strategy. Thus:

$$\sigma_rA\sigma_c^T=\sum_{i=1}^{m}{\sigma_r}_i(A\sigma_c^T)_i$$

Let $u=\max_{k}(A\sigma_c^T)_k$. Thus:

$$
\begin{align}
\sigma_rA\sigma_c^T&=\sum_{i=1}^{m}{\sigma_r}_i(u - u + (A\sigma_c^T)_i)\\
                   &=\sum_{i=1}^{m}{\sigma_r}_iu - \sum_{i=1}^{m}{\sigma_r}_i(u - (A\sigma_c^T)_i)\\
                   &=u - \sum_{i=1}^{m}{\sigma_r}_i(u - (A\sigma_c^T)_i)
\end{align}$$

We know that $u - (A\sigma_c^T)_i\geq 0$, thus the largest $\sigma_rA\sigma_c^T$ can be is $u$ which occurs iff ${\sigma_r}_i > 0 \Rightarrow (A\sigma_c^T)_i = u$ as required.

---

Returning to our previous example. If $\sigma_c=(1/2, 1/2)$, $(A\sigma_c^T)=(0, 0)$, thus $(A\sigma_c^T)_i = 0$ for all $i$.

Note that while any strategy is a best response to $(1/2, 1/2)$ the pair of strategies $(\sigma_r, \sigma_c) = ((1/2, 1/2), (1/2, 1/2))$ are the only two strategies that are best responses to each other. This _coordinate_ is called a **Nash equilibrium**.

## Definition of Nash equilibrium

[Video](https://youtu.be/b1JBFU0wDyY?list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

In a two player game $(A,B)\in{\mathbb{R}^{m\times n}}^2$, $(\sigma_r, \sigma_c)$ is a Nash equilibrium if $\sigma_r$ is a best response to $\sigma_c$ and vice versa.