### Import

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

import matplotlib
import matplotlib.pyplot as plt

from sympy import Matrix, init_printing

import qiskit
from qiskit import *

# Representing Data
from qiskit.providers.aer import QasmSimulator, StatevectorSimulator, UnitarySimulator
from qiskit.tools.visualization import plot_histogram, plot_state_city, plot_bloch_multivector

# Monitor Job on Real Machine
from qiskit.tools.monitor import job_monitor

from functools import reduce # perform sucessive tensor product

# Calculating cost
from sklearn.metrics import mean_squared_error

# Generating random unitary matrix
from scipy.stats import unitary_group
```

### Problem

Implement a circuit which returns |00> and |11> with equal probability.

Requirements :

- Circuit should consist only of CNOTs, RXs and RYs.

- Start from all parameters in parametric gates being equal to 0 or randomly chosen.

- You should find the right set of parameters using gradient descent (you might use more advanced optimization methods if you like).

- Simulations must be done with sampling - i.e. limited number of measurements per iteration and noise.

- Compare the results for different numbers of measurements: 1, 10, 100, 1000.

Bonus question:

- How to make sure you produce state $|00\rangle +|11 \rangle$ and not $|00\rangle - |11\rangle$ ?

### Helpful Resources

```python
# Matrices
I = np.array([[1, 0], [0, 1]])
X = np.array([[0, 1], [1, 0]])
Y = np.array([[0, -1j], [1j, 0]])
Z = np.array([[1, 0], [0, -1]])
H = 1/np.sqrt(2)*np.array([[1, 1], [1, -1]])

# Eigenvectors of Pauli Matrices
zero = np.array([[1], [0]]) # Z plus basis state
one = np.array([[0], [1]]) # Z plus basis state

plus = np.array([[1], [1]])/np.sqrt(2) # X plus basis state
minus = np.array([[1], [-1]])/np.sqrt(2) # X minus basis state

up = np.array([[1], [1j]])/np.sqrt(2) # Y plus basis state
down = np.array([[1], [-1j]])/np.sqrt(2) # Y plus basis state

# Bell States
B00 = np.array([[1], [0], [0], [1]])/np.sqrt(2) # Bell of 00
B01 = np.array([[1], [0], [0], [-1]])/np.sqrt(2) # Bell of 01
B10 = np.array([[0], [1], [1], [0]])/np.sqrt(2) # Bell of 10
B11 = np.array([[0], [-1], [1], [0]])/np.sqrt(2) # Bell of 11

# Rn Matrix Function
Rx = lambda theta: np.array([[np.cos(theta/2), -1j*np.sin(theta/2)], [-1j*np.sin(theta/2), np.cos(theta/2)]])
Ry = lambda theta: np.array([[np.cos(theta/2), -np.sin(theta/2)], [np.sin(theta/2), np.cos(theta/2)]])
Rz = lambda theta: np.array([[np.exp(-1j*theta/2), 0], [0, np.exp(1j*theta/2)]])
```

### View Matrix

```python
def view(mat):
    display(Matrix(mat))
```

### Get Unitary/StateVector Function

```python
# This function return the statevector or the unitary of an inputted circuit

def get(circ, types = 'unitary'):
    if types == 'statevector':
        backend = BasicAer.get_backend('statevector_simulator')
        out = execute(circ, backend).result().get_statevector()
    else:
        backend = BasicAer.get_backend('unitary_simulator')
        out = execute(circ, backend).result().get_unitary()

    return display(Matrix(np.round(out, 10)))
```

### Draw Neural Networks

```python
def draw_neural_net(ax, left, right, bottom, top, layer_sizes):
    '''
    Draw a neural network cartoon using matplotilb.

    :usage:
        >>> fig = plt.figure(figsize=(12, 12))
        >>> draw_neural_net(fig.gca(), .1, .9, .1, .9, [4, 7, 2])

    :parameters:
        - ax : matplotlib.axes.AxesSubplot
            The axes on which to plot the cartoon (get e.g. by plt.gca())
        - left : float
            The center of the leftmost node(s) will be placed here
        - right : float
            The center of the rightmost node(s) will be placed here
        - bottom : float
            The center of the bottommost node(s) will be placed here
        - top : float
            The center of the topmost node(s) will be placed here
        - layer_sizes : list of int
            List of layer sizes, including input and output dimensionality
    '''
    n_layers = len(layer_sizes)
    v_spacing = (top - bottom)/float(max(layer_sizes))
    h_spacing = (right - left)/float(len(layer_sizes) - 1)
    # Nodes
    for n, layer_size in enumerate(layer_sizes):
        layer_top = v_spacing*(layer_size - 1)/2. + (top + bottom)/2.
        for m in range(layer_size):
            circle = plt.Circle((n*h_spacing + left, layer_top - m*v_spacing), v_spacing/4.,
                                color='w', ec='k', zorder=4)
            ax.add_artist(circle)
    # Edges
    for n, (layer_size_a, layer_size_b) in enumerate(zip(layer_sizes[:-1], layer_sizes[1:])):
        layer_top_a = v_spacing*(layer_size_a - 1)/2. + (top + bottom)/2.
        layer_top_b = v_spacing*(layer_size_b - 1)/2. + (top + bottom)/2.
        for m in range(layer_size_a):
            for o in range(layer_size_b):
                line = plt.Line2D([n*h_spacing + left, (n + 1)*h_spacing + left],
                                  [layer_top_a - m*v_spacing, layer_top_b - o*v_spacing], c='k')
                ax.add_artist(line)

# Draw neural network for this problem
def draw_nn():
    fig = plt.figure(figsize=(8, 8))

    fig.text(0.15, 0.76, 'Input Layer', fontsize = 12)
    fig.text(0.75, 0.76, 'Output Layer', fontsize = 12)

    # Input Layer
    fig.text(0.177, 0.65, 'x_0', fontsize = 16)
    fig.text(0.177, 0.35, 'x_1', fontsize = 16)

    # Output Layer
    fig.text(0.795, 0.65, 'y_0', fontsize = 16)
    fig.text(0.795, 0.35, 'y_1', fontsize = 16)

    ax = fig.gca()
    ax.axis('off')
    draw_neural_net(ax, .1, .9, .1, .9, [2, 2])

```

### Gram-Schmidt Process

```python
def gs_cofficient(v1, v2):
    return numpy.dot(v2, v1) / numpy.dot(v1, v1)

def multiply(cofficient, v):
    return map((lambda x : x * cofficient), v)

def proj(v1, v2):
    return multiply(gs_cofficient(v1, v2) , v1)

def gs(X):
    Y = []
    for i in range(len(X)):
        temp_vec = X[i]
        for inY in Y :
            proj_vec = proj(inY, X[i])
            #print "i =", i, ", projection vector =", proj_vec
            temp_vec = map(lambda x, y : x - y, temp_vec, proj_vec)
            #print "i =", i, ", temporary vector =", temp_vec
        Y.append(temp_vec)
    return Y
```

### Plan

    1. Create a quantum circuit with Hamadamard and CX

    2. Decompose Hadamard into R_X and R_Y using gate decomposition

    3. Draw Neural Network graph

    4. Create data to train/test

    5. Initialized weights

    6. Implement Forward Propagation

    7. Define Cost function

    8. Implement Backpropagation

    9. Implement Gradient Descent to updates the weights in each layers

    10. Check that each layer is implemented correctly

    11. Train Model

    12. Evaluate Model

    13. Full Optimization Implementation

    14. Sampling on Quantum Circuit

Optional:

    - Implement Adam Optimization
    - Implement General Neural Network
    - Implement Forward Prop with simulation
    - Bonus question

### Step 1: Build Bell Circuit

Create a quantum circuit with Hadamard and CX

Typically, a circuit to create the $\frac{1}{\sqrt{2}}(|00\rangle + |11\rangle)$ looks like this

```python
# Quantum Circuit with Hadamard and CX
circ = QuantumCircuit(2)
circ.h(0)
circ.cx(0, 1)

circ.draw('mpl')
```

The outputs of the four CBS for this circuit are the following

Let matrix B represents the unitary of the circuit

$$B |00\rangle = \frac{1}{\sqrt{2}} (|00\rangle + |11\rangle)$$

$$B |01\rangle = \frac{1}{\sqrt{2}} (|00\rangle - |11\rangle)$$

$$B |10\rangle = \frac{1}{\sqrt{2}} (|01\rangle + |10\rangle)$$

$$B |11\rangle = \frac{1}{\sqrt{2}} (-|01\rangle + |10\rangle)$$

### Step 2: Hadamard Decomposition

Decompose Hadamard into $R_X(\phi)$ and $R_Y(\theta)$ using gate decomposition

#### Hadamard

$$H = \frac{1}{\sqrt{2}}\begin{bmatrix} 1 & 1\\ 1 & -1  \end{bmatrix}$$

#### X-axis Rotation

$$R_X( \theta ) = \begin{bmatrix} cos \frac{\theta}{2} & -i sin \frac{\theta}{2} \\ -i sin \frac{\theta}{2} & cos \frac{\theta}{2}  \end{bmatrix}$$

#### Y-axis Rotation

$$R_Y( \theta ) = \begin{bmatrix} cos \frac{\theta}{2} & -sin \frac{\theta}{2} \\ sin \frac{\theta}{2} & cos \frac{\theta}{2}  \end{bmatrix}$$

The Hadamard decomposition is as followed:

$$H = R_y(-\frac{\pi}{2})R_x(\pi)$$

Subsequently, the new circuit will looks like this

```python

# Implement a R_y, R_x circuit. Notice that the order is flipped
circ = QuantumCircuit(2)
circ.rx(np.pi, 0)
circ.ry(-np.pi/2, 0)
circ.cx(0, 1)

circ.draw('mpl')

```

The outputs of the four CBS for this circuit are the following

Let matrix M represents the unitary of the circuit

$$M |00\rangle = \frac{1}{\sqrt{2}} (|00\rangle + |11\rangle)$$

$$M |01\rangle = \frac{1}{\sqrt{2}} (|00\rangle - |11\rangle)$$

$$M |10\rangle = \frac{1}{\sqrt{2}} (|01\rangle + |10\rangle)$$

$$M |11\rangle = \frac{1}{\sqrt{2}} (-|01\rangle + |10\rangle)$$

This is the same as the normal Bell states circuit

### Step 3: Draw Neural Network

The neural network for this aims to mimic the $R_y(-\frac{\pi}{2})R_x(\pi)$ matrix complex

When plugging in the correct value, the $-i$ factors on the $R_x(\phi)$ eventually dissapear as a global phase.

Because of this, we can frame the $R_x(\phi)$ in a simpler form

$$R_y(\theta)R_x(\phi)(X) = \begin{bmatrix} cos \frac{\theta}{2} & -sin \frac{\theta}{2} \\ sin \frac{\theta}{2} & cos \frac{\theta}{2}  \end{bmatrix} \begin{bmatrix} cos \frac{\phi}{2} & sin \frac{\phi}{2} \\ sin \frac{\phi}{2} & cos \frac{\phi}{2}  \end{bmatrix} \begin{pmatrix} x_0 \\ x_1 \end{pmatrix}$$

The middle two matrices can be reduced into sum and difference formula

$$R_y(\theta)R_x(\phi)(X) = \begin{bmatrix} cos\frac{\phi + \theta}{2} & sin\frac{\phi - \theta}{2} \\ sin\frac{\phi + \theta}{2} & cos\frac{\phi - \theta}{2}\end{bmatrix} \begin{pmatrix} x_0 \\ x_1 \end{pmatrix}$$

This neural network has one layers, each layers with two nodes, and each nodes with two weights. We can set the weight matrix $W$ as follow:

$$W = \begin{bmatrix} cos\frac{\phi + \theta}{2} & sin\frac{\phi - \theta}{2} \\ sin\frac{\phi + \theta}{2} & cos\frac{\phi - \theta}{2}\end{bmatrix} = \begin{bmatrix} \alpha & \beta \\ \gamma & \delta \end{bmatrix}$$

```python
draw_nn()
```

###### Each nodes in the hidden layer and the output layer contains two weights

The square bracket index represents the layer the node is in.

The curly bracket index represents the position of the node in its respective layer.

###### Output Layer

$$y_0 = W^{[1]\{0\}} \cdot X$$

Where $W^{[1]\{0\}} = \begin{pmatrix} \alpha \\ \beta \end{pmatrix}$, $\alpha$ and $\beta$ are parameters

From this, we can see that the output equals

$$ y_0 = \alpha x_0 + \beta x_1 $$

Also,

$$y_1 = \gamma x_0 + \delta x_1$$

### Step 4: Create data

There are two approachs to doing this:

1. Create a small number of training examples so the algorithm intentionally overfits the data ( < 10 $(X, y)$ pairs)
2. Create a large number of training examples so the algorithm can be more robust ( > 1000 $(X, y)$ pairs)

Both approachs will be attempted and the better of the two will be chosen

Steps to creating data:

1. Randomize a 2-d real unit vectors, this is $X$ (for this purpose, ignore complex number)
2. Using the known matrix, take the product of the two, this is $y$
3. Repeat the process $n$ times as deemed necessary

#### Single Vector Implementation

```python
M_correct = H

### X-values

# Create a random 2-d real vector
x = np.random.rand(2, 1)

# Normalize v
x /= np.linalg.norm(x)

### Y-values

# Matrix Multiplication
y = np.dot(M_correct, x)
```

#### Multiple Vectors Implementation

```python
def create_data(M_correct, size = 500, train_split = 0.7, seed = 0):

    """

    Create two matrices of specified length consisting of 2-d vectors representing
    a train and a test examples

    Parameters
    ----------
    M_correct: ndarray
        Matrix that transform the input to the correct output
    size: int
        Number of examples (train & test) in the dataset
    train_split: float
        Proportion of the train data to the total dataset

    Returns
    -------
    X_train: ndarray
        Matrix containing the x-values of all the training examples
    y_train: ndarray
        Matrix containing the y-values of all the training examples
    X_test: ndarray
        Matrix containing the x-values of all the testing examples
    y_test: ndarray
        Matrix containing the y-values of all the testing examples

    """

    np.random.seed(seed)

    # Manage defaults for 'size'
    if size == 'small':
        size = 10
    elif size == 'large':
        size = 1000

    ### X-values

    # Create a random matrix of 2-d real vectors as columns
    X = np.random.rand (2, size)

    # Normalize v by column axix
    X /= np.linalg.norm(X, axis = 0)

    # Check for normalization
    assert np.round(np.sum(np.power(X, 2)), 10) == size, 'X is not normalized'

    # Check for correct shape
    assert X.shape == (2, size), 'the shape of X is not correct'

    ### Y-values

    # Matrix Multiplication
    y = np.dot(M_correct, X)

    # Check for unitary
    assert np.round(np.sum(np.dot(M_correct, M_correct.conj().T)), 10) == 2, 'M_correct is not unitary'

    # Check for correct shape
    assert y.shape == (2, size), 'the shape of y is not correct'

    # Splitting the matrix into its corresponding groups

    X_train = X[:, :int(size*train_split)]
    y_train = y[:, :int(size*train_split)]

    X_test = X[:, int(size*train_split):]
    y_test = y[:, int(size*train_split):]

    return X_train, y_train, X_test, y_test
```

### Step 5: Initialization
The $W$ matrix will be initialized to all zeros or randomly

```python
def initialize(init_type = 'random', dim = (2, 2), seed = 0):

    """

    Initialize the W matrix of a specified size to all zeros or randomly

    Parameters
    ----------
    init_type: str
        Specify whether W will be initialized to all zeros or randomly
    shape: tuple
        Specify shape of W
    seed: int
        Used for pseudo-randomly generate matrix

    Returns
    -------
    W: ndarray
        Initialized W matrix (all zeros or pseudo-random)

    """

    # Zero Initialization
    if init_type == 'zeros':

        W = np.zeros(dim)

    # Random Initialization
    else:
        # Keep the values consistent
        np.random.seed(seed)

        W = np.random.rand(*dim)

    # Check for W shape
    assert W.shape == dim, 'shape of W is not correct'

    return W
```

### Step 6: Forward Propagation

Forward propagation passes the input into the neural network to predict an output value

$$\hat{y} = \underbrace{\begin{bmatrix} \alpha & \beta \\ \gamma & \delta \end{bmatrix}}_{W} \begin{pmatrix} x_0 \\ x_1 \end{pmatrix}$$

To run multiple training examples at the same time (stochastic), put the training examples into a matrix a multiply it with the weight matrix. The index of training examples will be represented by the superscript inside the parentheses.

$$W(X) \ = \ W \begin{pmatrix} x_0^{(0)} & x_0^{(1)} ... & \ x_0^{(n)} \\ x_1^{(0)} & x_1^{(1)} ... & \ x_1^{(n)} \end{pmatrix} \ = \ \begin{pmatrix} \hat{y}^{(0)} & \hat{y}^{(1)} & ... & \hat{y}^{(n)}\end{pmatrix}$$

There are two ways of running forward propagation:

1. Simulate the circuit using 'qasm_simulator'

    - This is less practical because it requires taking the square root of the count probability to get back to the weights.

2. Calculate the output value using directly using linear algebra

    - Forward prop will be calculated using this method at the moment

```python
def forward_prop(W, X, path = None):

    """

    Perform the forward propagation step

    Parameters
    ----------
    W: ndarray
        Matrix of the weights in used
    X: ndarray
        Matrix of x-values
    path: str
        Specify whether the forward prop is done classically or using a quantum simulator

    Returns
    -------
    y_hat: ndarray
        Predicted value to be used for back-prop

    """

    # Simulate using 'qasm simulator'
    if path == 'sim':
        pass

    # Calculate using linear algebra
    else:
        y_hat = np.dot(W, X)

    # Make sure the shape is consistent
    assert X.shape == y_hat.shape, 'Shape is not consistent'

    return y_hat
```

### Step 7: Define Cost Function

Because this is a regression problem, we will calculate the cost using Half Mean Squared Error (MSE), which we will define as

$$J(W) = \frac{1}{2N} \sum^{N}_{i=1} \underbrace{||y_i - \hat{y}_i||^2}_{L(W)}$$

where $N$ is the number of training examples

```python
def cost(y, y_hat):

    """

    Compute half mean-squared error cost

    Parameters
    ----------
    y: ndarray
        The true y value generated from a preset matrix (see step 4: Create Data)
    y_hat: ndarray
        The predicted y value from a learned set of weights (see step 6: Forward Propagation)

    Returns
    -------
    J: float
        Scalar matrix to indicate the overall performance of the current set of weights

    """

    # N is number of training examples
    N = y.shape[1]

    # Compute cost function
    J = 1/(2*N) * np.sum(np.linalg.norm(y - y_hat)**2)

    return J
```

### Step 8: Backpropagation

Expanding the matrices from above (only caring about the subscript, we have)

$$\hat{y} = \begin{bmatrix} cos\frac{\phi + \theta}{2} & sin\frac{\phi - \theta}{2} \\ sin\frac{\phi + \theta}{2} & cos\frac{\phi - \theta}{2}\end{bmatrix} \begin{pmatrix} x_0 \\ x_1 \end{pmatrix} = \underbrace{\begin{bmatrix} \alpha & \beta \\ \gamma & \delta \end{bmatrix}}_{W} \begin{pmatrix} x_0 \\ x_1 \end{pmatrix} = \begin{pmatrix} \alpha x_0 + \beta x_1 \\ \gamma x_0 + \delta x_1 \end{pmatrix}$$

To find the local minimum, update the parameters opposite of the direction of steepest descent. Backpropagation calculates derivative of the cost function with respect to each of the weights.

From above

$$J(W) = \frac{1}{2N} \sum^{N}_{i=1} \underbrace{||y_i - \hat{y}_i||^2}_{L(W)}$$

The cost function can be written as

$$J(W) = \frac{1}{2N} \sum^{N}_{i=1} \begin{Vmatrix} y_0 - (\alpha x_0 + \beta x_1) \\ y_1 - (\gamma x_0 + \delta x_1) \end{Vmatrix}^2 = \frac{1}{2N} \sum^{N}_{i=1} \overbrace{{\underbrace{[(\alpha x_0 + \beta x_1 - y_0]}_{L_0}}^2 + {\underbrace{[\gamma x_0 + \delta x_1 - y_1]}_{L_1}}^2}^{L(W)}$$

In this case, the loss function $L(W)$ can be expressed as the sum of $L_0$ and $L_1$

$$L(W) = (L_0)^2 + (L_1)^2$$

Taking partial derivative with respect to a general weight ($w$)

$$\frac{\partial}{\partial w}J(W) = \frac{1}{2N} \sum^{N}_{i=1} \frac{\partial}{\partial w} L(W) = \frac{1}{2N} \sum^{N}_{i=1} \frac{\partial}{\partial w} [(L_0)^2 + (L_1)^2]$$

A $2$ can be factored out after taking the derivative of $(L_0)^2$ and $(L_1)^2$

$$= \frac{1}{N} \sum^{N}_{i=1} \begin{pmatrix} L_0 \frac{\partial L_0}{\partial w} + L_1 \frac{\partial L_1}{\partial w} \end{pmatrix}$$

Put everything together, the cost function derivatives with respect to $\alpha, \beta, \gamma$ and $\delta$ are

###### Alpha

$$\frac{\partial J}{\partial \alpha} = \frac{1}{N} \sum^{N}_{i=1} L_0 x_0$$

###### Beta

$$\frac{\partial J}{\partial \beta} = \frac{1}{N} \sum^{N}_{i=1} L_0 x_1$$

###### Gamma

$$\frac{\partial J}{\partial \gamma} = \frac{1}{N} \sum^{N}_{i=1} L_1 x_0$$

###### Delta

$$\frac{\partial J}{\partial \delta} = \frac{1}{N} \sum^{N}_{i=1} L_1 x_1$$

To implement these four calculations in code with multiple training examples,

1. Calculate the difference between all the $y$'s and $\hat{y}$'s

$$\text{diff} = \begin{pmatrix} y_0 - (\alpha x_0 + \beta x_1) \\ y_1 - (\gamma x_0 + \delta x_1) \end{pmatrix} = y - \hat{y} = \begin{pmatrix} L_0 \\ L_1 \end{pmatrix}$$

2. Elongate this 'diff' matrix & the $X$ matrix as follow and perform element-wise multiplication

$$\text{elem_prod} = \begin{bmatrix} \begin{pmatrix} L_0 \\ L_0 \end{pmatrix} \\ \begin{pmatrix} L_1 \\ L_1  \end{pmatrix} \end{bmatrix} * \begin{bmatrix} \begin{pmatrix} x_0 \\ x_1 \end{pmatrix} \\ \begin{pmatrix} x_0 \\ x_1 \end{pmatrix} \end{bmatrix} = \begin{pmatrix} L_0 x_0 \\ L_0 x_1 \\ L_1 x_0 \\ L_1 x_1\end{pmatrix}$$

3. Recall that each each $x$ and $L$ contains multiple training examples.

Take the average of the row of the matrix and unpack it to the appropriate derivative variable. Note that $\frac{\partial J}{\partial w}$ turns into $\partial w$

$$J = \frac{1}{N} \begin{pmatrix} \sum^{N}_{i=1} L_0 x_0 \\ \sum^{N}_{i=1} L_0 x_1 \\ \sum^{N}_{i=1} L_1 x_0 \\ \sum^{N}_{i=1} L_1 x_1\end{pmatrix} = \begin{pmatrix} \partial \alpha \\ \partial \beta \\ \partial \gamma \\ \partial \delta \end{pmatrix}$$

4. Finally reshape the weights into a square matrix for the gradient descent step

$$\begin{pmatrix} \partial \alpha \\ \partial \beta \\ \partial \gamma \\ \partial \delta \end{pmatrix} \longrightarrow \begin{bmatrix} \partial \alpha & \partial \beta \\ \partial \gamma & \partial \delta \end{bmatrix}$$

```python
def back_prop(y, y_hat, X):

    """
    Calculate derivatives of the cost function (see step 7: Define Cost Function)

    Parameters
    ----------
    y: ndarray
        The true y value generated from a preset matrix (see step 4: Create Data)
    y_hat: ndarray
        The predicted y value from a learned set of weights (see step 6: Forward Propagation)
    X: ndarray
        The X value used to generate the 'y' value

    Returns
    -------
    dJ: ndarray
        Matrix of partial derivatives with respect to each of the weights

    """

    # Extract number of examples
    N = y_hat.shape[1]

    # Reshape X
    X = np.reshape(X, (2, N))

    # Calculate the y - y_hat
    diff = y - y_hat

    # Elongate the diff array with repeating elements
    diff = np.repeat(diff, 2, axis = 0)

    # Reshape
    diff = np.reshape(diff, (4, N))

    # Elongate the x array with alternating elements
    X = np.vstack((X, X))

    # Check to see if both array has the same shape

    assert X.shape == diff.shape, 'Shape is not consistent'

    # Element-wise multiplication between the diff matrix and the x matrix
    elem_prod = diff * X

    # Average the row of the matrix
    dJ = (1/N) * np.sum(elem_prod, axis = 1)

    # Reshape

    dJ = np.reshape(dJ, (4, 1))

    # Check J shape
    assert dJ.shape == (4,1), 'Shape is not correct'

    # Reshape J into a matrix
    dJ = dJ.reshape(2, 2)

    assert dJ.shape == (2, 2), 'Shape is not square'

    return dJ
```

### Step 9: Gradient Descent (GD)

The general weight update with learning rate $\rho$ is as follow

$$w := w - \rho \frac{\partial J}{\partial w}$$

$\rho$ is defaulted as 0.001. However, once the implementation is sucessful, $\rho$ can be varied.

```python
def gradient_descent(weights, grad, learning_rate = 0.001):

    """

    Update the weights using gradients calculated from step 8: Backpropagation

    Parameters
    ----------
    weights: ndarray
        Matrix of weights used for training
    grad: ndarray
        Matrix of gradients calculated from step 8: Backpropagation
    learning_rate: float
        Determines the speed step size of the weights update

    Returns
    -------
    new_weights: ndarray
        Matrix of weights after updated

    """

    new_weights = weights - learning_rate * grad

    assert weights.shape == new_weights.shape

    assert new_weights.shape == (2, 2)

    return new_weights
```

### Step 10: Check for Correct Implementation

Checklist:

    1. Create train/test data
    2. Initialization
    3. Forward Propagation
    4. Define Cost Function
    5. Back Propagation
    6. Gradient Descent

##### 1. Create train/test data

Check the following:

1. Correct shape for both train and test set from 'size' and 'train_split'
2. The y matrix is the output of the X matrix with the M_correct

Run the code below to check

```python
for i in range(10_000):

    # Size of dataset
    n = np.random.randint(1, 10_000)

    # Train split
    split = np.random.uniform()

    # Random unitary matrix
    M = unitary_group.rvs(2)

    X_train, y_train, X_test, y_test = create_data(M, size = n, train_split = split)

    # Same shape for training pairs
    assert X_train.shape == y_train.shape

    # Same shape for testing pairs
    assert X_test.shape == y_test.shape

    # Correct no. of examples from 'size' and 'train_split'
    assert X_train.shape[1] == int(n * split)

    # Everything adds up to 'size'
    assert X_train.shape[1] + X_test.shape[1] == n

    # Check the output y is correct
    assert np.round(np.sum(np.dot(M, X_train) - y_train), 10) == 0

    assert np.round(np.sum(np.dot(M, X_test) - y_test), 10) == 0

print('Everything is good')

```

###### 2. Initialization

The code for this is simple enough to just be inspect by eye

###### 3. Forward Propagation

Checking by running through random examples

Run the code below to check

```python
for i in range(10_000):

    # Random weight matrix
    W = np.random.rand(2, 2)

    # Random X matrix
    n = np.random.randint(1, 10_000)
    X = np.random.rand(2, n)

    # Run function
    forward_prop(W, X, path = None)

print('Everything is good')
```

###### 4. Define Cost Function

This can be checked by comparing it to the 'mean_squared_error' within scikit learn

Run the code below to check

```python
for i in range(10_000):

    np.random.seed(i)

    n = np.random.randint(1, 10_000)
    y = np.random.rand(2, n)
    y_hat = np.random.rand(2, n)

    assert np.round(mean_squared_error(y, y_hat) - cost(y, y_hat), 15) == 0

print('Everything is good')
```

After 10_000 random examples, it can be seen that the two function is equivalent, when the first dimension of the matrix is 2. However, when this dimension is not 2, the difference is drastic. In the end, the cost(y, y_hat) function will be used because it is significantly faster.

###### 5. Back Propagation

Check the following:

1. Manually check if all the steps is implemented correctly

2. Check that back_prop(y, y, X) = 0, 0, 0, 0

3. Perform gradient checking

1. This is the first step. The code preceding the assert statement is a step-by-step break down (non-vectorized) implementation of the back-prop module. It is manually check for small matrix size. The result is the same for the first 100_000 examples

```python
for i in range(100_000):

    np.random.seed(i)

    n = np.random.randint(1, 10_000)

    X = np.random.rand(2, n)

    x_0 = X[0]
    x_0 = np.reshape(x_0, (1, n))

    x_1 = X[1]
    x_1 = np.reshape(x_1, (1, n))

    np.random.seed(i+1)

    y = np.random.rand(2, n)

    np.random.seed(i+2)

    y_hat = np.random.rand(2, n)

    diff = y - y_hat

    L_0 = diff[0]
    L_0 = np.reshape(L_0, (1, n))

    L_1 = diff[1]
    L_1 = np.reshape(L_1, (1, n))

    # Alpha
    d_alpha = (1/n) * np.sum(L_0 * x_0)

    # Beta
    d_beta = (1/n) * np.sum(L_0 * x_1)

    # Gamma
    d_gamma = (1/n) * np.sum(L_1 * x_0)

    # Delta
    d_delta = (1/n) * np.sum(L_1 * x_1)

    dJ = np.array([[d_alpha, d_beta], [d_gamma, d_delta]])

    assert np.sum(dJ - back_prop(y, y_hat, X)) == 0, 'not equal'
```

2. Check that back_prop(y, y, X) = 0, 0, 0, 0

This is correct for 10_000 random examples

```python
for i in range(10_000):
    n = np.random.randint(1, 10_000)
    X_train, y_train, X_test, y_test = create_data(H, size = n, train_split = 1, seed = i)

    assert np.all(back_prop(y_train, y_train, X_train) == np.zeros((2, 2)))
```

3. Grad Check

Run code below to compare 'back_prop' function and numerically calculated gradient

The numerical derivative will be calculated using a two-sided derivative

$$ \frac{\partial J}{\partial w} = \lim_{\varepsilon \to 0} \frac{J(w + \varepsilon) - J(w - \varepsilon)}{2 \varepsilon}$$

```python
for i in range(10_000):
    n = np.random.randint(1, 10_000)

    X_train, y_train, _ , _   = create_data(H, size = n, train_split=1, seed = i)

    W = initialize('random', dim = (2, 2), seed = i)

    y_hat = forward_prop(W, X_train)

    grad = back_prop(y_train, y_hat, X_train)

    epsilon = 1e-7

    # Create epsilon Matrix
    ep_alpha = np.array([[epsilon, 0], [0, 0]])

    ep_beta = np.array([[0, epsilon], [0, 0]])

    ep_gamma = np.array([[0, 0], [epsilon, 0]])

    ep_delta = np.array([[0, 0], [0, epsilon]])

    # Alpha

    # + epsilon on the alpha term
    alpha_plus = W + ep_alpha
    alpha_minus = W - ep_alpha

    # Forward Prop
    numerator = cost(y_train, forward_prop(alpha_plus, X_train)) - cost(y_train, forward_prop(alpha_minus, X_train))
    denominator = 2 * epsilon

    # Calculate Grad-appox
    d_alpha_appox = np.mean(numerator / denominator) * 2

    # Beta

    # + epsilon on the beta term
    beta_plus = W + ep_beta
    beta_minus = W - ep_beta

    # Forward Prop
    numerator = cost(y_train, forward_prop(beta_plus, X_train)) - cost(y_train, forward_prop(beta_minus, X_train))
    denominator = 2 * epsilon

    # Calculate Grad-appox
    d_beta_appox = np.mean(numerator / denominator) * 2

    # Gamma

    # + epsilon on the gamma term
    gamma_plus = W + ep_gamma
    gamma_minus = W - ep_gamma

    # Forward Prop
    numerator = cost(y_train, forward_prop(gamma_plus, X_train)) - cost(y_train, forward_prop(gamma_minus, X_train))
    denominator = 2 * epsilon

    # Calculate Grad-appox
    d_gamma_appox = np.mean(numerator / denominator) * 2

    # Delta

    # + epsilon on the delta term
    delta_plus = W + ep_delta
    delta_minus = W - ep_delta

    # Forward Prop
    numerator = cost(y_train, forward_prop(delta_plus, X_train)) - cost(y_train, forward_prop(delta_minus, X_train))
    denominator = 2 * epsilon

    # Calculate Grad-appox
    d_delta_appox = np.mean(numerator / denominator) * 2

    # Put all the numerical gradient into a matrix
    grad_appox = np.array([[d_alpha_appox, d_beta_appox], [d_gamma_appox, d_delta_appox ]])

    assert np.round(np.linalg.norm(grad_appox - grad * -2), 7) == 0
```

After implementing grad check, it is found that the current 'back_prop' method generates a gradient matrix that is a factor of -2 away from the grad check value.

The L2 norm between 'grad' (function) and 'grad_appox' (numerical) is zero, when round to 7 decimal places for 10_000 random examples.

This suggests a certain correctness to the implementation of both system.

It is more likely that the problem lies in the 'back_prop' function since there is inherently more calculation in that step

###### 6. Gradient Descent

The code for this is simple enough to just be inspect by eye

### Step 11: Train Model

Now that everything is checked, the training can start. Implement the code by the following order

    1. Create train/test data (X_train, y_train, X_test, y_test)
    2. Initialize weights (W)

    Loop

    3. Forward Propagation (y_hat)
    4. Define Cost Function (L)
    5. Back Propagation (grad) * -2
    6. Gradient Descent (W)

    Finally

    7. Normalized the matrix

The dataset is created before the model is trained
The input of the 'train' method are
    1. X-values
    2. y-values
    3. Weight initialization type ( = None ('random') )
    4. Number of iterations (= 50)
    5. Learning rate (= 0.1)
    6. Loss / mse print frequency ( = 10)
    7. Random seed ( = 0)
    8. Print weights ( = True)

```python
def train(X, y, init_weights = 'random', iterations = 50,
          learning_rate = 0.1, print_frequency = 10, seed = 0, print_weights = True):

    """
    Train model from (X, y) pairs

    Parameters
    ----------
    X: ndarray
        X-values for training
    y: ndarray
        y-values for the corresponding X-values
    init_weights: str
        'random' or 'zeros'
    iterations: int
        Number of weights updates through forward / backpropagation
    learning_rate: float
        Step size for weights update
    print_frequency: int
        Loss / mse print frequency
    seed: int
        Used for pseudo-randomly generate matrix
    print_weights: bool
        Show weights at the end for visual comparison

    Returns
    -------
    W: ndarray
        Final weights after training

    """

    # Weights initialization
    if type(init_weights) == str:
        W = initialize(init_type = init_weights, seed = 0)
    else:
        W = init_weights

    print('Training: \n')

    for i in range(iterations):

        # Foward Propagation
        y_hat = forward_prop(W, X)

        # Compute cost
        L = cost(y, y_hat)

        # Compute mse
        mse = cost(W, M_correct)

        # Print loss and mse
        if i % print_frequency == 0:
            print(f'{i}th iteration, loss = {np.round(L, 6)}, mse = {np.round(mse, 6)}')

        # Backpropagation
        grad = back_prop(y, y_hat, X) * -2 # Multiply by a factor of -2 from above

        # Gradient Descent (The bigger the learning rate, the faster the model trains <= 1)
        W = gradient_descent(W, grad, learning_rate = learning_rate)

    # Normalized W matrix

    W /= np.linalg.norm(W, axis = 0)

    # Print weights
    if print_weights:
        print('\n')
        print('Final Weights')
        view(W)
        print('\n')

    return W
```

### Step 12: Evaluate Model

Test the weights of the created test set. The metrics here is 'mse'.

The parameters for this functions are:

    1. Trained weights
    2. X-values test set
    3. y-values test set

The function will return a 'mse' metrics

```python
def predict(trained_weights, X, y):

    """
    Test model from (X, y) pairs

    Parameters
    ----------
    trained_weights: ndarray
        Weights from previously trained model
    X: ndarray
        X-values for testing
    y: ndarray
        y-values for the corresponding X-values

    """
    print('Evaluation: \n')

    # Foward Propagation
    y_hat = forward_prop(trained_weights, X)

    # Compute mse
    mse = cost(y, y_hat)

    print(f'mse = {mse}')
```

### Step 13: Full Optimization Implementation

```python
# Define correct matrix
M_correct = H

print('Correct Matrix:')
view(M_correct)
print('\n')

# Create data
X_train, y_train, X_test, y_test = create_data(M_correct, size = 5)

# Train model
model_weights = train(X_train, y_train, init_weights = 'random', iterations = 100, learning_rate = 1, print_frequency=20)

# Evaluate model
predict(model_weights, X_test, y_test)
```

    Correct Matrix:

$\displaystyle \left[\begin{matrix}0.707106781186547 & 0.707106781186547\\0.707106781186547 & -0.707106781186547\end{matrix}\right]$

    Training:

    0th iteration, loss = 0.349286, mse = 0.400872
    20th iteration, loss = 0.024185, mse = 0.027757
    40th iteration, loss = 0.001675, mse = 0.001922
    60th iteration, loss = 0.000116, mse = 0.000133
    80th iteration, loss = 8e-06, mse = 9e-06

    Final Weights

$\displaystyle \left[\begin{matrix}0.707072740664112 & 0.707902073187454\\0.707140820070332 & -0.706310593702873\end{matrix}\right]$

    Evaluation:

    mse = 3.8293921714689e-07

### Step 14: Sampling on Quantum Circuit

To sample qubits on a quantum circuit, the weights has to a in the form of a unitary matrix.

This can be acheived by applying the Gram-Schmidt process to the matrix. The resulting orthonormal matrix is unitary in real numbers.

To create an orthonormal basis for the set of vectors $\{v_1, v_2\}$,
$$u_1 = \frac{v_1}{||v_1||}\$$

$$y_2 = v_2 - (v_2 \cdot u_1) \ u_1$$

$$u_2 = \frac{y_2}{||y_2||}$$

At the end, we have

$$\{v_1, v_2\} \longrightarrow \{u_1, u_2\}$$

```python
# Gram-Schmidt for 2D real vector sets

def gs_2d_real(M):

    # Split into individual components
    v1 = M[:, 0].reshape(2, 1)
    v2 = M[:, 1].reshape(2, 1)

    # Normalize
    u1 = v1 / np.linalg.norm(v1)

    # Calculate orthogonal vector
    y2 = v2 - np.dot(v2.T, u1) * u1

    # Normalize
    u2 = y2 / np.linalg.norm(y2)

    # Put back to a matrix
    ortho_M = np.hstack((u1, u2))

    # Check for unitary
    assert np.round(np.sum(np.dot(ortho_M, ortho_M.conj().T)), 10) == 2, 'ortho_M is not unitary'

    return ortho_M
```

Once we have the weights as unitary matrix, we can feed it into the circuit to sample qubits

```python
# Orthonormalize the weights
U = gs_2d_real(model_weights)

# Initialize Quantum Circuit
circ = QuantumCircuit(2, 2)

# Apply weights matrix as unitary gate
circ.unitary(U, 0)

# Apply CX
circ.cx(0, 1)

# Measure
circ.measure([0, 1], [0, 1])

# Draw Circuit
circ.draw('mpl')
```

```python
simulator = Aer.get_backend('qasm_simulator')

# For Mathematical Representation
results = execute(circ, simulator).result()

# Count Results
counts = results.get_counts(circ)

# Plot Histogram
plot_histogram(counts)
```

```python

```


Primal Dual Hybrid Gradient Algorithm: Tomography

 In this demo, we learn how to use the Primal Dual hubrid algorithm introduced by [Champolle & Pock](https://hal.archives-ouvertes.fr/hal-00490826/document). We focus on Tomography Reconstruction under an edge-preserving prior, i.e., the __Total variation regularisation__, see [ROF](https://en.wikipedia.org/wiki/Total_variation_denoising).

The minimisation problem for Total variation Tomography reconstuction is

$$ \underset{u}{\operatorname{argmin}} \frac{1}{2} \| \mathcal{A} u - g\|^{2} + \alpha\,\mathrm{TV}(u) +  \mathbb{I}_{\{u>0\}}(u) $$

where,
1. The total variation is defined as $$\mathrm{TV}(u) = \|\nabla u \|_{2,1} = \sum \sqrt{ (\partial_{y}u)^{2} + (\partial_{x}u)^{2} }$$
1. g is the Acqusisition data obtained from the detector.
1. $\mathcal{A}$ is the projection operator ( _Radon transform_ ) that maps from an image-space to an acquisition space, i.e., $\mathcal{A} : X \rightarrow Y, $ where X is an __ImageGeometry__ and Y is an __AcquisitionGeometry__.
1. $\alpha$: regularising parameter that measures a trade-off between the fidelity and the regulariser terms.
1. $\mathbb{I}_{\{u>0\}}(u) : =
\begin{cases}
0, & \mbox{ if } u>0\\
\infty , & \mbox{ otherwise}
\quad
\end{cases}
$, $\quad$ a positivity constraint for the minimiser $u$.

## Learning objectives

1. Brief intro for non-smooth minimisation problem using PDHG algorithm.
1. Setup and run PDHG with (__non-smooth__) $L^{1}$ norm regulariser. __(No BlockFramework)__
1. Use __BlockFunction__ and setup PDHG within the __Block Framework__.
1. Run Total variation reconstruction with different regularising parameters and compared with FBP and SIRT reconstructions.

At the end of this demo, we will be able to reproduce all the reconstructions presented in the figure below. One can observe that the __Tikhonov regularisation__ with $L = \nabla$ was able to remove the noise but could not preserve the edges. However, this can be achieved with the the total variation reconstruction.

## Prerequisites

- AcquisitionData, AcquisitionGeometry, AstraProjectorSimple.
- BlockOperator, Gradient.
- FBP, SIRT, CGLS, Tikhonov.

In order to use the PDHG algorithm for the problem above, we need to express our minimisation problem into the following form:

$$\min_{u} \mathcal{F}(K u) + \mathcal{G}(u)$$

where we assume that:

1. $\mathcal{F}$, $\mathcal{G}$ are __convex__ functionals

    - $\mathcal{F}: Y \rightarrow \mathbb{R}$

    - $\mathcal{G}: X \rightarrow \mathbb{R}$

2. $K$ is a continuous linear operator acting from a space X to another space Y :

$$K : X \rightarrow Y \quad $$

with operator norm  defined as $$\| K \| = \max\{ \|K x\|_{Y} : \|x\|_{X}\leq 1 \}.$$

**Note**: The Gradient operator  has  $\|\nabla\| = \sqrt{8} $ and for the projection operator we use the [Power Method](https://en.wikipedia.org/wiki/Power_iteration) to approximate the greatest eigenvalue of $K$.

```python
# Imports
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

from ccpi.framework import ImageData, TestData, ImageGeometry, AcquisitionGeometry, AcquisitionData, BlockDataContainer

from ccpi.optimisation.functions import L2NormSquared, ZeroFunction, L1Norm, BlockFunction, MixedL21Norm, IndicatorBox, FunctionOperatorComposition
from ccpi.optimisation.operators import Gradient, BlockOperator
from ccpi.optimisation.algorithms import PDHG, SIRT, CGLS

from ccpi.astra.operators import AstraProjectorSimple, AstraProjector3DSimple
from ccpi.astra.processors import FBP

import tomophantom
from tomophantom import TomoP2D
import os, sys

import matplotlib.pyplot as plt

import numpy as np

from utilities import islicer, link_islicer, psnr, plotter2D
from utilities.show_utilities import show

%matplotlib inline

```

## Why PDHG?

In the previous demo, we presented the __Tikhonov regularisation__ for tomography reconstruction, i.e.,

$$ \underset{u}{\operatorname{argmin}} \|\mathcal{A} u - g\|^{2}_{2} + \alpha^{2}\|L u\|^{2}_{2} $$

where we set $L = \nabla $ or $L = \mathbb{I}$. Due to the $\|\cdot\|^{2}_{2}$ terms, one can observe that the above objective function is differentiable. However, this is not always the case. Consider for example an $L^{1}$ norm for the fidelity, i.e., $\|\mathcal{A} u - g\|_{1}$ or an $L^{1}$ norm of the regulariser i.e., $\|u\|_{1}$.

Using the __Primal-Dual hybrid gradient algorithm__, we can solve minimisation problems where the objective is not differentiable, and only a convexity is required.

The algorithm is described in the [Appendix](#Appendix) and for every iteration, we solve two subproblems, i.e., __primal & dual problems__ where the __[proximal operators](#Proximal)__ have a closed form solution or can be solved efficiently using an iterative solver.

### L$^{1}$ regularisation

Let $L=\mathbb{I}$ in [Tikhonov regularisation](#Tikhonov) and replace the

$$\alpha^{2}\|L u\|^{2}_{2}\mbox{  with  } \alpha\|u\|_{1}, $$

which results to a non-differentiable objective function. Hence, we have

$$ \underset{u}{\operatorname{argmin}} \|\mathcal{A} u - g\|^{2}_{2} + \alpha\|u\|_{1}. $$

Now, we would like to setup and run PDHG algorithm for the above minimisation problem.

First, we load a phantom, from the [Tomophantom](https://github.com/dkazanc/TomoPhantom) package. We can
choose different 2D,3D & 4D phantoms from this [library](https://github.com/dkazanc/TomoPhantom/tree/master/PhantomLibrary/models). For the well-known Shepp-Logan phantom, use **_model = 1_** in the code below.

```python
model = 12 # select a model number from the library
N = 256 # set dimension of the phantom
path = os.path.dirname(tomophantom.__file__)
path_library2D = os.path.join(path, "Phantom2DLibrary.dat")

phantom = TomoP2D.Model(model, N, path_library2D)

# Define image geometry.
ig = ImageGeometry(voxel_num_x = N, voxel_num_y = N,
                   voxel_size_x = 0.1,
                   voxel_size_y = 0.1)
im_data = ig.allocate()
im_data.fill(phantom)

show(im_data, title = 'TomoPhantom', cmap = 'inferno')
```

The next step is to create an __AcquisitionGeometry__ and __AcquisitionData__ using a __Projection Operator__ $\mathcal{A}$.

```python
# Create AcquisitionGeometry and AcquisitionData
detectors = N
angles = np.linspace(0, np.pi, 180, dtype='float32')
ag = AcquisitionGeometry('parallel','2D', angles, detectors,
                        pixel_size_h = 0.1)

# Create projection operator using Astra-Toolbox. Available CPU/CPU
A = AstraProjectorSimple(ig, ag, device = 'gpu')

# Create an acqusition data (numerically)
sino_num = A.direct(im_data)

# Show numerical sinogram
show(sino_num, title = 'Numerical Sinogram', labels = ['Detectors','Angle'], cmap = 'inferno')

# Simulate Gaussian noise for the sinogram
gaussian_var = 0.2
gaussian_mean = 0 # or np.mean(sino_num.as_array())
n1 = np.random.normal(gaussian_mean, gaussian_var, size = ag.shape)

sino_noisy = ag.allocate()
sino_noisy.fill(n1 + sino_num.as_array())
show(sino_noisy, title = 'Noisy Sinogram', labels = ['Detectors','Angle'], cmap = 'inferno')
```

## FBP and SIRT reconstuctions

Let's start with a Filtered Back Projection reconstruction and Simultaneous Iterative Reconstruction Technique.
Recall, for FBP we type

` fbp = FBP(ig, ag, filter_type = 'ram-lak', device = 'gpu') `

` fbp.set_input(sino_noisy)`

` fbp_recon = fbp.get_output()`

and the available filters from Astra are:

`
‘ram-lak’, ‘shepp-logan’, ‘cosine’, ‘hamming’, ‘hann’, ‘none’, ‘tukey’, ‘lanczos’, ‘triangular’, ‘gaussian’, ‘barlett-hann’, ‘blackman’, ‘nuttall’, ‘blackman-harris’, ‘blackman-nuttall’, ‘flat-top’, ‘kaiser’, ‘parzen’, ‘projection’, ‘sinogram’, ‘rprojection’, ‘rsinogram’.
`

For SIRT, we type

` x_init = ig.allocate()`

` sirt = SIRT(x_init = x_init, operator = A, data=sino_noisy, constraint = IndicatorBox(lower=0),max_iteration = 50)`

` sirt.run(verbose=False) `

` sirt_recon = sirt.get_output() `

 __(Reminder)__ : If $\mathcal{A} u = g$, then:  $$u^{k+1} = \mathcal{P}_{C}(u^{k} + D \mathcal{A}^{T} M ( g - \mathcal{A} u^{k}))$$ where,
 $$
    \begin{cases}
    M = \frac{\mathbb{1}}{\mathcal{A}\mathbb{1}}, \quad m_{ii} = \frac{1}{\sum_{j} a_{ij}}, \quad\mbox{ sum over columns }\\
    D = \frac{\mathbb{1}}{\mathcal{A}^{T}\mathbb{1}}, \quad d_{jj} = \frac{1}{\sum_{i} a_{ij}}\quad\mbox{ sum over rows }\\
    \end{cases}
    $$

and $\mathcal{P}_{C}$ is a projection onto a convex set C. For this demo, we will use a positivity constraint.

```python
# Setup and run the FBP algorithm

fbp = FBP(ig, ag, filter_type = 'hann', device = 'gpu')
fbp.set_input(sino_noisy)
fbp_recon = fbp.get_output()
show(fbp_recon, title = 'FBP reconstruction', cmap = 'inferno')
```

```python
# Setup and run the SIRT algorithm, with positivity constraint

x_init = ig.allocate()

sirt = SIRT(x_init = x_init, operator = A, data=sino_noisy, constraint = IndicatorBox(lower=0),
           max_iteration = 50)
sirt.run(verbose=False)

sirt_recon = sirt.get_output()
show(sirt_recon, title = 'SIRT reconstruction', cmap = 'inferno')
```

## How to setup and run PDHG

In order to setup and run PDHG, we need to define the following:

- The operator K.
- The function $\mathcal{F}$ and $\mathcal{G}$.
- Step-sizes $\sigma$ and $\tau$ such that $\sigma\tau\|K\|^{2}<1$, see [Appendix](#Appendix).

The setup and run of PDHG:

` pdhg = PDHG(f = F, g = G, operator = K, tau = tau, sigma = sigma, max_iterations = maxiter)`

` pdhg.run()`

We can express our [problem](#Lasso) into this [form](#PDHG_form), if we let

1. $K = \mathcal{A} \quad \Longleftrightarrow \quad $ `K = A`

1. $\mathcal{F}: Y \rightarrow \mathbb{R}, \mbox{ with } \mathcal{F}(z) := \frac{1}{2}\| z - g \|^{2}, \quad \Longleftrightarrow \quad$ ` F = 0.5 * L2NormSquared(sino_noisy)`

1. $\mathcal{G}: X \rightarrow \mathbb{R}, \mbox{ with } \mathcal{G}(z) := \alpha\|z\|_{1}, \quad \Longleftrightarrow \quad$ ` G = alpha * L1Norm()`.

**Note**: In order to use the PDHG algorithm, we do not need to setup the objective function, but rather the functions $\mathcal{F}, \mathcal{G}$ and operator $K$ that form the objective.

Hence, we can verify that with the above setting we end up with this [form](#PDHG_form) for $x=u$,  $$\underset{u}{\operatorname{argmin}} \frac{1}{2}\|\mathcal{A} u - g\|^{2}_{2} + \alpha\|u\|_{1} =
\underset{u}{\operatorname{argmin}} \mathcal{F}(\mathcal{A}u) + \mathcal{G}(u) = \underset{x}{\operatorname{argmin}} \mathcal{F}(Kx) + \mathcal{G}(x) $$

### Define operator K, functions $\mathcal{F}$  and $\mathcal{G}$

```python
K = A
F = 0.5 * L2NormSquared(b=sino_noisy)
alpha = 0.5
G = alpha * L1Norm()
```

### Compute operator norm and step-sizes $\sigma, \tau$, such that $\sigma\tau\|K\|^{2}<1$

```python
normK = K.norm()
sigma = 1/normK
tau = 1/normK
```

### Setup and run PDHG

```python
# Setup and run PDHG
pdhg = PDHG(f = F, g = G, operator = K, sigma = sigma, tau = tau,
            max_iteration = 500,
            update_objective_interval = 100)
pdhg.run()

pdhg_l1_recon = pdhg.get_output()
```

### Show reconstruction result

```python
# Compute PSNR quality measure, and show reconstuction and ground truth
PSNR = psnr(im_data.as_array(), pdhg_l1_recon.as_array())

show(pdhg_l1_recon, title = 'Reconstruction: L1 regularisation: PSNR = {0:.2f}'.format(PSNR), cmap = 'inferno')
show(im_data, title = 'Ground Truth', cmap = 'inferno')

# Plot middle line profile
plt.figure(figsize=(30,15))
plt.rcParams.update({'font.size': 22})
plt.rcParams.update({'lines.linewidth': 5})
plt.plot(np.linspace(1,N,N),im_data.subset(horizontal_y = 128).as_array(), label = 'Ground Truth')
plt.plot(np.linspace(1,N,N),pdhg_l1_recon.subset(horizontal_y = 128).as_array(), label = 'L1 Reconstruction')
plt.legend()
plt.title('Middle Line Profiles')
plt.show()
```

## Exercise 1: Regularising parameter

Run the code above, using different regularisation parameters:

1. $\alpha = 1e-6$, $\alpha = 1e6$. What do you observe for these asympotic values of $\alpha$.
1. Add more [noise](#noise), e.g., __gaussian_var = 5__ and try to find a _good_ regularisation parameter.

## Exercise 2: PDHG Convergence

We say that the PDHG converges if the __Primal-Dual Gap = Primal objective + Dual objective__ $\rightarrow 0 $,  see  [here](#Appendix) .

Moreover, the speed of convergence of the PDHG algorithm depends heavily on the choise of step-sizes $\sigma$ and $\tau$. Run the [code](#sigma_tau) above with $\sigma=1$, $\tau = \frac{1}{\sigma \|K\|^2}$ and 500 iterations. What do you observe?

# Total Variation Regularisation

Now, we can continue with the setup of the PDHG algorithm using the Total variation regulariser defined [here](#TomoTV). Compared to the [Tikhonov](#Tikhonov) problem, we use $L = \nabla$ but with a non-smooth term which is the __MixedL21Norm__ define [here](#TomoTV). In addition, we enforce a positivity constraint for our solution using $\mathbb{I}_{\{u>0\}}(u)$.

## BlockFunction

In order to setup the above [problem](#TomoTV) we need to use the __BlockFramework__ and in particular the concepts of __BlockOperator__, __BlockDataContainer__ and __BlockFunction__. In the previous demos, we have already discussed BlockOperator and BlockDataContainer.

BlockFunction behaves similarly to a BlockOperator/BlockDataContainer but instead of _stacking_ operators or DataContainers we _append_ functions as below:

$$ F = [f_{1}, f_{2}]$$

For example, let

$$\begin{align}
f_{1}: Y \rightarrow \mathbb{R}, \quad f_{1}(z_{1}) = \alpha\,\|z_{1}\|_{2,1}, \mbox{ ( the TV term ) }\\
f_{2}: X \rightarrow \mathbb{R}, \quad f_{2}(z_{2}) = \frac{1}{2}\|z_{2} - g\|_{2}^{2}, \mbox{ ( the data-fitting term ) }
\end{align}$$

and consider $z = (z_{1}, z_{2})\in Y\times X$, then

$$F(z) : = F((z_{1},z_{2}) = f_{1}(z_{1}) + f_{2}(z_{2})$$

## Why BlockFunction?

With the above form, $F(z)$ is a __separable sum__ of decoupled functions. __So, why do we need to write in this form__? It turns out, that the __proximal operator of a BlockFunction__ can be separated, i.e.,

$$\mathrm{prox}_{\tau F}(z) =
\begin{bmatrix}
\mathrm{prox}_{\tau f_1}(z_1)\\
\mathrm{prox}_{\tau f_2}(z_2)
\end{bmatrix} =
\begin{bmatrix}
\underset{w_{1}\in Y}{\operatorname{argmin}} \tau f_{1}(w_{1}) + \frac{1}{2}\|w_{1} - z_{1}\|^{2}\\
\underset{w_{2}\in X}{\operatorname{argmin}} \tau f_{2}(w_{2}) + \frac{1}{2}\|w_{2} - z_{2}\|^{2}
\end{bmatrix}
$$

Hence, in order to solve the __dual problem__ of the [PDHG](#Appendix) algorithm, we need to solve the two decoupled problems as shown above.

## PDHG for TV regularisation

At this stage, we have all the necessary ingredients in order to setup our PDHG algorithm for Total variation tomography reconstruction. We define K as a BlockOperator, with the Gradient and Projection operator:

$$ K =
\begin{bmatrix}
\nabla\\
A
\end{bmatrix}
$$

` K = BlockOperator(Grad, A)`

The function $\mathcal{F}$, is a [BlockFunction](#BlockFunction) with

1. a function $\alpha\|\cdot\|_{2,1}\quad\Longleftrightarrow\quad$ ` alpha * MixedL21Norm() ` term that represents the Total variation regularisation ,

1. a function $\frac{1}{2}\|\cdot -g \|_{2}^{2}\quad\Longleftrightarrow\quad$ `0.5 * L2NormSquared(sino_noisy)` term that represents the data fitting.

Hence, $F = [f1, f2] \quad\Longleftrightarrow\quad $ ` F = BlockFunction( MixedL21Norm(), L2NormSquared(sino_noisy))`

Finally, we have the function $\mathcal{G} = \mathbb{I}_{\{u>0\}}(u) \quad\Longleftrightarrow\quad$ ` G = IndicatorBox(lower=0)`

Again, we can verify that with the above setting we can express our problem into this [form](#PDHG_form), for $x=u$

$$
\begin{align}
\underset{u}{\operatorname{argmin}}\alpha\|\nabla u\|_{2,1} + \frac{1}{2}\|\mathcal{A} u - g\|^{2}_{2} + \mathbb{I}_{\{u>0\}}(u) =  \underset{u}{\operatorname{argmin}} f_{1}(\nabla u) + f_{2}(\mathcal{A}u) + \mathbb{I}_{\{u>0\}}(u) \\ = \underset{u}{\operatorname{argmin}} F(
\begin{bmatrix}
\nabla \\
\mathcal{A}
\end{bmatrix}u) + \mathbb{I}_{\{u>0\}}(u) =
\underset{u}{\operatorname{argmin}} \mathcal{F}(Ku) + \mathcal{G}(u) = \underset{x}{\operatorname{argmin}} \mathcal{F}(Kx) + \mathcal{G}(x)
\end{align}
$$

## Exercise 2: Setup and run PDHG algorithm with the Total variation regulariser

Follow the steps below:

1. Define Gradient operator and BlockOperator K. The ImageGeometry, AcquisitionGeometry and Projection Operator and are already defined.
1. Define BlockFunction F ( do not forget the regularisation parameter ).
1. Define Function G.
1. Computer operator norm of K, sigma and tau.
1. Setup and run PDHG.
1. Show reconstruction result.

The reconstruction will look like the figure below after running PDHG for 1000 iterations, with $\sigma=1$, $\tau = \frac{1}{\sigma\|K\|^{2}}$ and $\alpha=2$.

```python
# Define Gradient operator and BlockOperator K

### START CODE HERE (2 lines) ###

    # Grad = Gradient(...)
    # K = BlockOperator(... , ...  )

### END CODE HERE ###

# Define BlockFunction F using the MixedL21Norm() and the L2NormSquared()

### START CODE HERE (4 lines) ###

    # alpha = 2
    # f1 = MixedL21Norm(...)
    # f2 = L2NormSquared(...)
    # F = BlockFunction(...  , ... )

### END CODE HERE ###

# Define BlockFunction G, as a positivity constraint

### START CODE HERE (1 lines) ###

    # G = IndicatorBox(...)

### END CODE HERE ###

# Compute operator norm and choose step-size sigma and tau such that sigma*tau||K||^{2}<1 (3 lines)

### START CODE HERE (3 lines) ###

    # normK = ...
    # sigma = ...
    # tau = ...

### END CODE HERE ###

# Setup and run PDHG

### START CODE HERE (1 lines) ###

    # pdhg = ..
    # pdhg.run()
    # pdhg_tv_recon = pdhg.get_output()

### END CODE HERE ###

# Show reconstruction, compared middle line profiles

# show(pdhg_tv_recon, title = 'TV reconstruction: PSNR = {0:.2f}'.format(PSNR), cmap = 'inferno')
# show(im_data, title = 'Ground Truth', cmap = 'inferno')

# # Plot middle line profiles
# plt.figure(figsize=(30,15))
# plt.rcParams.update({'font.size': 22})
# plt.rcParams.update({'lines.linewidth': 5})
# plt.plot(np.linspace(1,N,N),im_data.subset(horizontal_y = 128).as_array(), label = 'Ground Truth')
# plt.plot(np.linspace(1,N,N),pdhg_tv_recon.subset(horizontal_y = 128).as_array(), label = 'TV Reconstruction')
# # plt.plot(np.linspace(1,N,N),pdhg_l1_recon.subset(horizontal_y = 128).as_array(), label = 'L1 Reconstruction')
# plt.legend()
# plt.title('Middle Line Profiles')
# plt.show()
```

```python
# Define Gradient Operator and BlockOperator
Grad = Gradient(ig)
K = BlockOperator(Grad,A)

# Define BlockFunction F using the MixedL21Norm() and the L2NormSquared()
alpha = 2
f1 =  alpha * MixedL21Norm()
f2 = 0.5 * L2NormSquared(b=sino_noisy)
F = BlockFunction(f1,f2)

# Define BlockFunction G, as a positivity constraint
G = IndicatorBox(lower=0)

# Compute operator norm and choose step-size sigma and tau such that sigma*tau||K||^{2}<1
normK =  K.norm()
sigma = 1
tau = 1/(sigma*normK**2)

pdhg = PDHG(f = F, g = G, operator = K, tau = tau, sigma = sigma,
            max_iteration = 1000, update_objective_interval = 500)
pdhg.run(verbose = True)

pdhg_tv_recon = pdhg.get_output()

# Compute PSNR quality measure, and show reconstuction and ground truth
PSNR = psnr(im_data.as_array(), pdhg_tv_recon.as_array(), data_range=np.max(im_data.as_array()))

show(pdhg_tv_recon, title = 'TV reconstruction: PSNR = {0:.2f}'.format(PSNR), cmap = 'inferno')
show(im_data, title = 'Ground Truth', cmap = 'inferno')

# Plot middle line profiles
plt.figure(figsize=(30,15))
plt.rcParams.update({'font.size': 22})
plt.rcParams.update({'lines.linewidth': 5})
plt.plot(np.linspace(1,N,N),im_data.subset(horizontal_y = 128).as_array(), label = 'Ground Truth')
plt.plot(np.linspace(1,N,N),pdhg_tv_recon.subset(horizontal_y = 128).as_array(), label = 'TV Reconstruction')
# plt.plot(np.linspace(1,N,N),pdhg_l1_recon.subset(horizontal_y = 128).as_array(), label = 'L1 Reconstruction')
plt.legend()
plt.title('Middle Line Profiles')
plt.show()
```

## Exercise 3: Find the best regulariser for TV reconstruction and compare with FBP and SIRT algorithms

For this exercise, we ask the user to

1. Run TV reconstruction with 5 different regularising parameters, e.g., $\alpha = [0.05, 0.5, 2, 5, 10]$.
1. Choose the best reconstruction according to the highest PSNR.

```python
# Create a list of possible regularisation parameters

### START CODE HERE (1 line) ###

    # alpha = [ , , , , , ]

### END CODE HERE ###

# Compute operator norm and choose step-size sigma and tau such that sigma*tau||K||^{2}<1

### START CODE HERE (3 lines) ###

    # normK = ...
    # sigma = ...
    # tau = ...

### END CODE HERE ###

# Allocate space for reconstruction results
    # recon_all_geometry = ImageGeometry(channels=len(alpha), voxel_num_x = N, voxel_num_y = N)
    # recon_all = recon_all_geometry.allocate()
    # PSNR = np.zeros(len(alpha))

# Define function G, and data-fitting term that is an function-element of the BlockFunction

### START CODE HERE (2 lines) ###

    # G = ...
    # f2 = ...

### END CODE HERE ###

# Run PDHG for different regularising parameters

### START CODE HERE (3 lines) ###

    #for i in range(len(alpha)):

    #    f1 = ...
    #    F = BlockFunction(..., ...)

    #    pdhg = PDHG(f = ..., g = ..., operator = ..., tau = ..., sigma = ...,
    #            max_iteration = 1000, update_objective_interval = 500)
    #    pdhg.run(verbose = True)

    #    recon_all[i] = pdhg.get_output().as_array()

        # Compute PSNR quality measure, and show reconstuction and ground truth
    #    PSNR[i] =  compare_psnr(im_data.as_array(), pdhg.get_output().as_array(), data_range = 1 )

    #    show(pdhg.get_output(), title = 'TV reconstruction: alpha = {0:.2f}'.format(alpha[i]))
    #    plt.pause(0.5)

### END CODE HERE ###

# Print Parameters and PSNR values

    # print('The parameters are {}  '.format(alpha))
    # print('The PSNR values {}  '.format(PSNR))

# Use islicer to scroll over the different reconstructions

# from utilities import islicer
# stack_recon = ImageData(np.array([fbp_recon.as_array(),
#                                   sirt_recon.as_array(),
#                                   pdhg_l1_recon.as_array(),
#                                   recon_all.subset(channel=0).as_array(),
#                                   recon_all.subset(channel=1).as_array(),
#                                   recon_all.subset(channel=2).as_array(),
#                                   recon_all.subset(channel=3).as_array(),
#                                   recon_all.subset(channel=4).as_array(),
#                                   im_data.as_array()]))
# # from ccpi.framework import BlockDataContainer
# # stack_recon = BlockDataContainer(fbp_recon, sirt_recon)
# islicer(stack_recon, 0, title = ['FBP','SIRT','L1 reconstruction',
#                                  'TV a = {}'.format(alpha[0]),
#                                  'TV a = {}'.format(alpha[1]),
#                                  'TV a = {}'.format(alpha[2]),
#                                  'TV a = {}'.format(alpha[3]),
#                                  'TV a = {}'.format(alpha[4]),
#                                  'Ground Truth'],
#         minmax=(0,1), size=(10,10), slice_number = 0)
```

```python
# Setup a list of regularising parameters

alpha = [0.05, 0.5, 2, 5, 10]

# Compute operator norm and choose step-size sigma and tau such that sigma*tau||K||^{2}<1
K = BlockOperator(Grad, A)

normK = K.norm()
sigma = 1
tau = 1/(sigma*normK**2)

# sigma = 1/normK
# tau = 1/normK

# Allocate space for reconstruction results
recon_all_geometry = ImageGeometry(channels=len(alpha), voxel_num_x = N, voxel_num_y = N)
recon_all = recon_all_geometry.allocate()
PSNR = np.zeros(len(alpha))

# Define BlockFunction G, as a positivity constraint
G = IndicatorBox(lower=0)
f2 = 0.5 * L2NormSquared(b=sino_noisy)

for i in range(len(alpha)):

    f1 =  alpha[i]* MixedL21Norm()
    F = BlockFunction(f1, f2)

    pdhg = PDHG(f = F, g = G, operator = K, tau = tau, sigma = sigma,
            max_iteration = 1000, update_objective_interval = 500)
    pdhg.run(verbose = True)

    np.copyto(recon_all.as_array()[i],pdhg.get_output().as_array())

    # Compute PSNR quality measure, and show reconstuction and ground truth
    PSNR[i] =  psnr(im_data.as_array(), pdhg.get_output().as_array(), data_range=1)

    show(pdhg.get_output(), title = 'TV reconstruction: alpha = {0:.2f}'.format(alpha[i]), cmap = 'inferno')
    plt.pause(0.5)

```

```python
print('The parameters are {}  '.format(alpha))
print('The PSNR values {}  '.format(PSNR))
```

```python
from utilities import islicer
stack_recon = ImageData(np.array([fbp_recon.as_array(),
                                  sirt_recon.as_array(),
                                  pdhg_l1_recon.as_array(),
                                  recon_all.subset(channel=0).as_array(),
                                  recon_all.subset(channel=1).as_array(),
                                  recon_all.subset(channel=2).as_array(),
                                  recon_all.subset(channel=3).as_array(),
                                  recon_all.subset(channel=4).as_array(),
                                  im_data.as_array()]))
# from ccpi.framework import BlockDataContainer
# stack_recon = BlockDataContainer(fbp_recon, sirt_recon)
islicer(stack_recon, 0, title = ['FBP','SIRT','L1 reconstruction',
                                 'TV a = {}'.format(alpha[0]),
                                 'TV a = {}'.format(alpha[1]),
                                 'TV a = {}'.format(alpha[2]),
                                 'TV a = {}'.format(alpha[3]),
                                 'TV a = {}'.format(alpha[4]),
                                 'Ground Truth'],
        minmax=(0,1), size=(10,10), slice_number = 0)
```

```python
plt.figure(figsize=(30,15))
plt.rcParams.update({'font.size': 22})
plt.rcParams.update({'lines.linewidth': 5})
plt.plot(np.linspace(1,N,N),fbp_recon.subset(horizontal_y = 128).as_array(), label = 'FBP', color = 'brown')
plt.plot(np.linspace(1,N,N),fbp_recon.subset(horizontal_y = 128).as_array(), label = 'SIRT', color = 'orange')
plt.plot(np.linspace(1,N,N),pdhg_l1_recon.subset(horizontal_y = 128).as_array(), label = 'L1 Reconstruction', color = 'blue')
plt.plot(np.linspace(1,N,N),recon_all.subset(channel=1, horizontal_y=128).as_array(), label = 'TV Reconstruction', color = 'red')
plt.plot(np.linspace(1,N,N),im_data.subset(horizontal_y = 128).as_array(), label = 'Ground Truth', color = 'black')
plt.legend()
plt.title('Middle Line Profiles')
plt.show()
```

### Demo Summary

1. Brief intro for non-smooth minimisation problem using PDHG algorithm.
1. Setup and run PDHG with (__non-smooth__) $L^{1}$ norm regulariser. __(No BlockFramework)__
1. Use __BlockFunction__ and setup PDHG within the __Block Framework__.
1. Run Total variation reconstruction with different regularising parameters and compared with FBP and SIRT reconstructions.

# Appendix

The PDHG algorithm is described below :

 Choose step-sizes $\tau$, $\sigma$, such that $\tau\sigma\|K\|^{2}<1$
 Choose $\theta\in[0,1]$
 Let $(x^{0}, y^{0})\in X\times Y$ and $\overline{x}^{0} = x^{0}$

\begin{align}
    y^{n+1}  & = \mathrm{prox}_{\sigma\mathcal{F}^{*}}( y^{n} + \sigma K \overline{x}^{n} )\quad \mbox{ (Dual Problem)}\tag{1}\\[10pt]
    x^{n+1}  & = \mathrm{prox}_{\tau\mathcal{G}} ( x^{n} - \tau K^{*} \overline{x}^{n} )\quad \mbox{ (Primal Problem) }\tag{2}\\[10pt]
    \overline{x}^{n+1} & = x^{n+1} + \theta ( x^{n+1} - x^{n} )\quad\mbox{ (Over-relaxation step) }
\end{align}

__**Proximal Operator**__: Let $\lambda>0$ and $f$ a convex function. Then

$$
z : = \mbox{prox}_{\lambda f}(x) = \underset{z}{\operatorname{argmin}} \frac{1}{2}\|z - x \|^{2} + \lambda f(z)
$$

__**Note**__: We assume that proximal operators have a closed form solution or can be solved efficiently using an iterative algorithm.

__**Primal-Dual Gap (PD)**__:

$$\mbox{PD} = \mathcal{F}(Kx) + \mathcal{G}(x) + \mathcal{F}^{*}(y) + \mathcal{G}(-K^{T}y) $$


```
%matplotlib inline
from matplotlib.pyplot import plot, figure, legend, xticks, yticks
```

```
from numpy import logspace, linspace, log10, sin, cos, arcsin, sqrt, abs, degrees, arange
```

Sea un sistema lineal invariante en el tiempo descrito por la siguiente EDO

$$\left( \frac{d^2}{dt^2} + \frac{d}{dt} \right) y(t) = \left( \frac{d}{dt} - 1 \right) u(t)$$

donde $y(t)$ es la salida y $u(t)$ es la entrada.

# Funcion de Transferencia

#### Encuentre su funcion de transferencia $\frac{y(s)}{u(s)}$. Cuales son los polos y los ceros?

Para obtener la función de transferencia del sistema dado se debe obtener la transformada de Laplace.

$$\mathcal{L} \left[ \left( \frac{d^2}{dt^2} + \frac{d}{dt} \right) y(t) = \left( \frac{d}{dt} - 1 \right) u(t)\right] \implies (s^2 + s) y(s) = (s - 1) u(s) \implies \frac{y(s)}{u(s)} = \frac{s-1}{s(s+1)}$$

Por lo tanto podemos notar que tiene un cero en {$1$} y dos polos en {$ 0, -1$}.

# Routh-Hurwitz

#### Como primer intento para controlar a este sistema, se propone una ley de control, $\frac{u(s)}{v(s)} = k\frac{s-a}{s+b}$, donde $k>0, a>0, b>0$. Utilizando el criterio de Routh Hurwitz, determine las relaciones que debe guardar la ganancia $k$ con los paramteros $a$ y $b$ para que el sistema en lazo cerrado sea Hurwitz estable.

Para utilizar el criterio de estabilidad de Routh-Hurwitz, tenemos que encontrar la funcion de transferencia del sistema retroalimentado con la ley de control dada, de donde tenemos que si la ley de control es:

$$\frac{u(s)}{v(s)} = k\frac{s-a}{s+b}$$

esto nos indica que la esta actuando directamente sobre la entrada del bloque del sistema, por lo que el sistema retroalimentado lo podemos ver como:

$$FT_{lc} = \frac{\frac{u(s)}{v(s)} \frac{y(s)}{u(s)}}{1 + \frac{u(s)}{v(s)} \frac{y(s)}{u(s)}}$$

o bien:

$$FT_{lc} = \frac{G_c G}{1 + G_c G}$$

donde $G = \frac{y(s)}{u(s)}$ y $G_c = \frac{u(s)}{v(s)}$

Asi pues, empezamos importando la libreria simbolica de Python y declarando las variables necesarias para el calculo de la funcion de transferencia retroalimentada $FT_{lc}$:

```
from sympy import var, latex, simplify, collect, expand, init_printing, solve
init_printing()
var("s,a,b,k")
```

```
G = (s-1)/(s*(s+1))
Gc = k*(s-a)/(s+b)
FTlc = simplify((Gc*G)/(1+Gc*G))
```

```
FTlc
```

o equivalentemente:

$$\frac{k \left(s - a\right) \left(s -1 \right)}{k \left(s - a\right) \left(s -1\right) + s \left(s + b \right) \left(s + 1\right)}$$

lo cual nos indica que el polinomio caracetristico es:

$$F(s) = k \left(s - a \right) \left(s -1\right) + s \left(s + b \right) \left(s + 1\right)$$

```
F = k*(s-a)*(s-1) + s*(s+b)*(s+1)
```

```
collect(expand(F),s)
```

Por lo que notamos que la tabla de Routh se calculara haciendo lo siguiente:

$$a s^3 + b s^2 + c s + d$$

|$s^3$|$a$                  |$c$|
|-----|---------------------|---|
|$s^2$|$b$                  |$d$|
|$s^1$|$\frac{b c - d a}{b}$|$0$|
|$s^0$|$d$                  |$0$|

Y el criterio que nos da Routh-Hurwitz para evaluar la estabilidad de un sistema nos dice que habra tantas raices con parte real positiva, como cambios de signo haya en la primera columna, por lo que nuestro trabajo es hacer que la primera columna no cambie de signo. Por consiguiente tenemos:

1. $$1 > 0$$

2. $$b + k + 1 > 0$$

3. $$\frac{((b + k + 1)(b - k - ak) - (a k)(1))}{(b + k + 1)} > 0$$

4. $$ak > 0$$

De la primera desigualdad, tenemos algo trivial, de la segunda obtenemos que $b + k > -1$, pero como $b > 0$ y $k > 0$ esto no nos dice nada mas. En la cuarta desigualdad tenemos de igual manera que $a > 0$ y $k > 0$, por lo que esta debe de ser cierta y no nos brinda mayor información. La tercer desigualdad la tenemos que desarrollar.

```
simplify(expand(((b+k+1)*(b-k-a*k) - (a*k)*1)/(b+k+1)))
```

Lo cual implica:

$$\frac{- a b k - a k^{2} - 2 a k + b^{2} + b - k^{2} - k}{b + k + 1} > 0$$

pero como ya establecimos, la segunda desigualdad es cierta, por lo que podemos escribir lo siguiente como equivalencia:

$$- a b k - a k^{2} - 2 a k + b^{2} + b - k^{2} - k > 0$$

```
cond = collect(-a*b*k - a*k**2 - 2*a*k + b**2 + b - k**2 - k,k)
cond
```

o bien:

$$k^{2} \left(- a -1\right) + k \left(- a b - 2 a -1\right) + \left( b^{2} + b \right)$$

```
cond_k = solve(cond, k)
cond_k
```

Lo que calculamos son los valores criticos de k, por lo que:

$$
k > \frac{1}{2 a + 2} \left(- a b - 2 a + \sqrt{a^{2} b^{2} + 4 a^{2} b + 4 a^{2} + 4 a b^{2} + 6 a b + 4 a + 4 b^{2} + 4 b + 1} - 1\right)
$$
$$
k > - \frac{1}{2 a + 2} \left(a b + 2 a + \sqrt{a^{2} b^{2} + 4 a^{2} b + 4 a^{2} + 4 a b^{2} + 6 a b + 4 a + 4 b^{2} + 4 b + 1} + 1\right)
$$

#### Proponga valores numéricos para los parámetros $a$ y $b$, y determine el rango de ganancias $k$ para que el sistema en lazo cerrado sea Hurwitz estable. Cual es la ganancia crítica, $k_c$, para la cual , el sistema en lazo cerrado presenta una respuesta oscilatoria?

Asi pues podemos proponer $a$ y $b$ mayores que $0$, de tal manera que obtengamos un rango de $k$, en el que el comportamiento del sistema sea estable.

Si proponemos $a = 2$ y $b = 2$.

```
cond1 = cond_k[0].subs(a, 2).subs(b, 2)
cond1
```

```
cond1.evalf()
```

```
cond2 = cond_k[1].subs(a, 2).subs(b, 2)
cond2
```

```
cond2.evalf()
```

Entonces obtenemos que $k > 0.561$ y $k > -3.561$, por lo que la unica restriccion para k será que $k > 0.561$ y por lo tanto para $a = 2$ y $b = 2$, $k_c = 0.561$.

# Lugar de las Raices

#### Compruebe el rango encontrado en el anterior inciso, utilizando el lugar de las raices

Para utilizar las funciones comunes de Matlab, podemos utilizar el modulo de control de python, el cual mandamos llamar.

```
from control import tf, rlocus
```

Asi pues, podemos usar el comando rlocus para obtener los datos necesarios para graficar el lugar geometrico de las raices.

```
G = tf([1, -1],[1, 1, 0])
Gc = tf([1, -2],[1, 2])
```

```
f = figure(figsize = (11, 5))
rlocus(G.feedback(tf([1],[1])))
ax = f.gca()
ax.set_xlim([-3.5, 1.5]);
legend(("Polos", "Ceros"));
```

```
f = figure(figsize = (11, 5))
rlocus(Gc*G)
ax = f.gca()
ax.set_xlim([-2.5, 2.5])
legend(("Polos", "Ceros"));
```

#### Determine una ganancia $k$, para que los polos dominantes tengan un coeficiente de amortiguamiento de $0.5$ ¿Cuál será el porcentaje de sobrepaso $M_p$? ¿Donde se localizan los polos dominantes? ¿Cuáles son los valores de sus frecuencias naturales no amortiguada, $\omega_n$, y amortiguada $\omega_d$?

Si tomamos en cuenta el coeficiente de amortiguamiento de $\zeta = 0.5$, tenemos que el angulo $\phi$ debe de ser:

```
from math import acos, degrees
```

```
phi = acos(0.5)
degrees(phi)
```

Es decir que el angulo al que encontraremos una $k$ para la cual el coeficiente de amortiguamiento $\zeta$ sera de $0.5$ es de $60º$

```
f = figure(figsize = (11, 5))
rlocus(Gc*G)
p2, = plot([-1, 0], [sqrt(3), 0])
ax = f.gca()
ax.set_xlim([-5.5, 2.5])
ax.set_ylim([-1, 1])
legend(("Polos", "Ceros", "RL", "RL", "RL", r"$\zeta = 0.5$"));
```

Si nos acercamos a la zona en que el lugar de las raices cruza con el amortiguamiento $\zeta = 0.5$, tendrémos:

```
f = figure(figsize = (10, 10))
rlocus(Gc*G)
p2, = plot([-1, 0], [sqrt(3), 0])
ax = f.gca()
ax.set_xlim([-1, 0.1])
ax.set_ylim([-0.1, 1])
xticks(arange(-1, 0.1, 0.1))
yticks(arange(-0.1, 1, 0.05));
```

Aqui podemos ver claramente que la intersección es aproximadamente en $(-0.2, 0.35) = -0.2 + 0.35j$, por lo que vamos a querer colocar un polo en este punto y su conjugado.

De esta manera, podemos obtener la frecuencia no amortiguada con la magnitud de este numero complejo y la frecuencia amortiguada con la componente imaginaria del mismo

```
polo = -0.2 + 0.35j
wd = polo.imag
wn = abs(polo)
print "La frecuencia no amortiguada es " + str(wd) + " y la amortiguada es " + str(wn) + "."
```

    La frecuencia no amortiguada es 0.35 y la amortiguada es 0.403112887415.

Para obtener $k$, tenemos la condición de magnitud del sistema, la cual nos dice:

$$
\left| G_C(s) G(s) \right| = 1$$

por lo que tendremos que:

$$
k (s-2) (s-1) + s (s+2) (s+1) = 1 \implies k = \frac{s(s+2)(s+1)}{(s-2)(s-1)}
$$

```
F = lambda s: (s*(s+2)*(s+1))/((s-2)*(s-1))
F(-0.2 + 0.35j)
```

    (-0.23179848866498745+0.0013047858942065596j)

Lo cual, teniendo en cuenta redondeo, dejaremos en $-0.23$.

```
K = tf([-0.23], [1])
```

Para obtener el sobrepaso maximo podemos simular la salida para una entrada tipo escalon unitario, y el sobrepaso maximo porcentual estará dado por:

$$
M_p = \frac{G(t_p) - G(\infty)}{G(\infty)}
$$

```
from control import step_response
```

```
t, y = step_response((K*Gc*G).feedback(tf([1], [1])))
```

```
y.max()
```

```
y[-1]
```

```
plot(t, y)
```

```

```

IPythonNotebooks/Propedeutico/Examen de Admision a Maestria de Control Automatico 2013.ipynb


# Random Forest Tutorial

This notebook servers as an introduction to Random Forest which is a tree based ensemble learning algorithms. In this notebook I will cover the technical details behind random forest and it's suitability as a (supervised) anomaly detection tool.

## Decision Trees
Given:

$D = \{(x_1, y_1), ...., (x_n, y_n)\}$

$h_k(x)$ is the $k_{th}$ decision tree and $h_k(x) = h(x | \theta_k)$ where $\theta_k = (\theta_{k1}, \theta_{k2},....\theta_{kp})$ are the parameters of the decision tree which include the structure of the tree, which features are used at which node to create a new split, split value at a particular node etc. When used in ensembles, each decision tree works on a particular sub sample of the training data (sampled without replacement). This sampling parameter is encapsulated in the ensemble model parameter $\Theta$.

## Random Forest

It is simply an ensemble of many such trees and calculates the final class value for a given sample by majority vote.
\begin{equation}
\therefore f(x) = Mode\{h_k(x)\}
\end{equation}

Each tree is grown as follows:

* If the number of cases in the training set is N, sample N cases at random - but with replacement, from the original data. This sample will be the training set for growing the tree.
* If there are M input features, a number m<<M is specified such that at each node, m features are selected at random out of the M and the best split on these m is used to split the node. The value of m is held constant during the forest growing.
* Each tree is grown to the largest extent possible. There is no pruning.
* The unbiased error estimate is obtained as the *out-of-bag (oob) error estimate*.About 1/3rd of samples from the training dataset are left out when growing a tree. After the tree is fully grown then class estimate for every sample that is *oob* is obtained by majority vote and this prediction is then compared to the label.

In the original paper on random forests, it was shown that the forest error rate depends on two things:

* The *correlation* between any two trees in the forest. Increasing the correlation increases the forest error rate.
* The *strength* of each individual tree in the forest. A tree with a low error rate is a strong classifier. Increasing the strength of the individual trees decreases the forest error rate.

```python
%matplotlib inline
import seaborn as sns
import pandas as pd
import numpy as np
import datetime
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, accuracy_score, zero_one_loss
from Utils.utils import conf_matrix, read_mat

# default plot settings
plt.rcParams['figure.figsize'] = [16, 14]
plt.rcParams['axes.facecolor'] = 'turquoise'
plt.rcParams['axes.titlepad'] = 15.0
rng = np.random.RandomState(42)
```

```python
customPalette = ['#630C3A', '#39C8C6', '#D3500C', '#FFB139']
sns.set_palette(customPalette)
sns.palplot(customPalette)
def plot_data(dframe,x='x', y='y',col='label'):
    sns.lmplot(data=dframe, x=x, y=y, col=col,
                   fit_reg=False, legend=True, legend_out=True)
```

```python
def plot_prep(data_splits, model, actual_inlier, model_inlier, model_name="model"):
    X_test, y_test = data_splits
    y_pred = model.predict(X_test)
    y_test_new = []
    for yt in y_test.tolist():
        if yt==actual_inlier:
            y_test_new.append('Good')
        else:
            y_test_new.append('Bad')

    y_pred_new = []
    for yt in y_pred.tolist():
        if yt==model_inlier:
            y_pred_new.append('Good')
        else:
            y_pred_new.append('Bad')

    data = np.stack((X_test[:, 2], X_test[:,1], y_test_new, y_pred_new)).T
    new_df = pd.DataFrame(data, columns=['x', 'y', '{}.actual'.format(model_name), '{}.predicted'.format(model_name)])
    new_df['x'] = new_df['x'].astype('float')
    new_df['y'] = new_df['y'].astype('float')
    return (new_df, 'x', 'y', ('{}.actual'.format(model_name), '{}.predicted'.format(model_name)))
```

```python
def conf_prep(df, model_name):
    y_act = df['{}.actual'.format(model_name)].values.tolist()
    y_pred = df['{}.predicted'.format(model_name)].values.tolist()

    y_act = [0 if x=='Good' else 1 for x in y_act]
    y_pred = [0 if x=='Good' else 1 for x in y_pred]
    return (y_act, y_pred, [0,1])
```

```python
filepath = 'resources/anomaly_datasets/mammography.mat'
df_mammography = read_mat(filepath)
df_mammography.head()
```

      feat-1
      feat-2
      feat-3
      feat-4
      feat-5
      feat-6
      label

      0
      0.230020
      5.072578
      -0.276061
      0.832444
      -0.377866
      0.480322
      0

      1
      0.155491
      -0.169390
      0.670652
      -0.859553
      -0.377866
      -0.945723
      0

      2
      -0.784415
      -0.443654
      5.674705
      -0.859553
      -0.377866
      -0.945723
      0

      3
      0.546088
      0.131415
      -0.456387
      -0.859553
      -0.377866
      -0.945723
      0

      4
      -0.102987
      -0.394994
      -0.140816
      0.979703
      -0.377866
      1.013566
      0

```python
Data = df_mammography.iloc[:,0:df_mammography.shape[1]-1].values
Target = df_mammography['label'].values
X_train, X_test, y_train, y_test = train_test_split(Data, Target, test_size=0.2, random_state=rng, stratify=Target)
print('Training Features Shape:', X_train.shape)
print('Training Labels Shape:', y_train.shape)
print('Testing Features Shape:', X_test.shape)
print('Testing Labels Shape:', y_test.shape)

#criterion = measure the quality of a split, a good split will lead to better class division among samples.

#A split point at any depth will only be considered if it leaves at least min_samples_leaf training samples
#in each of the left and right branches.

#max_features = no. of features to consider when looking for the best split. When "auto", max_features = sqrt(num_features)

rfc = RandomForestClassifier(n_estimators=1000, random_state=rng, max_features=1.,
                                 class_weight='balanced', n_jobs=-1)

rfc.fit(X_train,y_train)
```

    Training Features Shape: (8946, 6)
    Training Labels Shape: (8946,)
    Testing Features Shape: (2237, 6)
    Testing Labels Shape: (2237,)

    RandomForestClassifier(bootstrap=True, class_weight='balanced',
                criterion='gini', max_depth=None, max_features=1.0,
                max_leaf_nodes=None, min_impurity_decrease=0.0,
                min_impurity_split=None, min_samples_leaf=1,
                min_samples_split=2, min_weight_fraction_leaf=0.0,
                n_estimators=1000, n_jobs=-1, oob_score=False,
                random_state=<mtrand.RandomState object at 0x7fcf9c9b6b88>,
                verbose=0, warm_start=False)

```python
df1_rf, plot_x, plot_y, cols = plot_prep((X_test, y_test),rfc, 0, 0, "rforest")
plot_data(df1_rf, x=plot_x, y=plot_y, col=cols[0])
plot_data(df1_rf, x=plot_x, y=plot_y, col=cols[1])
```

```python
test1_rf, pred1_rf, labels1_ref = conf_prep(df1_rf, "rforest")
fig = plt.figure(figsize=(12,10))
conf_matrix(test1_rf, pred1_rf, fig, labels= labels1_ref, model_name="Random Forest", z=1)
plt.show()
```

```python
print(classification_report(test1_rf, pred1_rf, target_names=['Regular', 'Anomaly']))
print("accuracy of classification = {}%".format(accuracy_score(test1_rf, pred1_rf)*100))
print("misclassification rate = {}%".format(zero_one_loss(test1_rf, pred1_rf)*100))
```

                  precision    recall  f1-score   support

         Regular       0.99      1.00      0.99      2185
         Anomaly       0.83      0.48      0.61        52

       micro avg       0.99      0.99      0.99      2237
       macro avg       0.91      0.74      0.80      2237
    weighted avg       0.98      0.99      0.98      2237

    accuracy of classification = 98.56951274027715%
    misclassification rate = 1.4304872597228457%

### Salient Features of Random Forests:

* Overcome the overfitting problem of decision trees by sampling both the data and features.
* It can handle thousands of input variables without variable deletion.
* It gives estimates of what variables are important in the classification.
* It has methods for balancing error in class population unbalanced data sets.
* The capabilities of the above can be extended to unlabeled data, leading to unsupervised clustering, data views and outlier detection.

```python

```


# Covid-19 prediction and Analysis
> Prediction and analysis of cases using curve fitting of growth curves.

In December 2019, China found the first patient infected with Covid-19(Coronavirus  Disease  19).   The virus is a member of novel coronaviruses.  The patient has reportedly caught the virus from the seafood market in Wuhan which is the epicenter of Covid-19.  Since then Covid-19 has spread all over the world.  On  11  March  2020, the World Health  Organization declared  Covid-19  as  Pandemic. Steps such as lockdown and quarantine have become imperative. But how exactly are these measures shaping the curve and what impact it will do on the future trend can be studied using certain curves. Here we try to predict the future trend and impact of lockdown measures by fitting the current cumulative cases with some particular growth curves. We will introduce these curves later. First, let's see how curve fitting is done.

## What is Curve Fitting?
Curve fitting is the process of constructing a curve or mathematical function, that has the best fit to a series of data points, possibly subject to constraints. The most popular method for curve fitting is to use least-squares minimization, however other methods exist as well. Our problem is unconstrained, and according to scipy documentation, "Default method to use for optimization is ‘lm’ for unconstrained problems and ‘trf’ if bounds are provided."
lm or Levenberg–Marquardt algorithm is used to solve non-linear least squares problems.

This model is based on the research paper  "Prediction and analysis of Coronavirus Disease 2019" by LinJia, Keen Li, Yu Jiang, Xin Guo, Ting Zhao They tested their model on the trend of SARS disease. Find it [here](https://arxiv.org/ftp/arxiv/papers/2003/2003.05447.pdf). Since the SARS and Covid-19 are both coronaviruses, these same models can be implemented here. Lets now introduce the 3 types of the curve we will be fitting.

##  Growth Curves
- Logistic/Sigmoid fuction

A logistic function or logistic curve is a common S-shaped curve (sigmoid curve). It is commonly to explore the risk factors of a certain disease, and predict the probability of occurrence of a certain disease according to the risk factors.
\begin{align}
f(x) = \frac{a}{1+e^{b-c(t-t_0)}}
\end{align}
- Bertalanffy function

Bertalanffy model is often used as a growth model. It is used to describe the growth characteristics of fish. Other species can also be used to describe the growth of animals, such as pigs, horses, cattle, sheep, etc. and other infectious diseases.
\begin{align}
f(x) = a(1-e^{-b(t-t_0)})^c
\end{align}
- Gompertz function

The model was originally proposed by Gomperts (Gompertz,1825) as an animal population growth
model to describe the extinction law of the population. The development of infectious diseases is similar to the growth of individuals and populations.
\begin{align}
f(x) = ae^{-be^{-c(t-t_0)}}
\end{align}

In above models, $f(x)$ are the confirmed cases, $a$ is predicted maximum confirmed cases $b$ and $c$ are hyperparameter, $t$ is number of days since first case is reported and $t_0$ is day on which first case occurred.
- Model Evaluation

The regression coefficient $(R^2)$ is used to evaluate the fitting ability of various methods and can be obtained by the following equation.
\begin{align}
R^2 = 1-\frac{\sum(y_i - \hat{y_i})^2}{\sum(y_i - \bar{y})^2}
\end{align}
$𝑦_𝑖$ is the actual cumulative confirmed COVID-19 cases; $\hat{y_i}$ is the predicted cumulative confirmed COVID-19 cases; $\bar{y}$ is the average of the actual cumulative confirmed COVID-19 cases.
The closer the fitting coefficient is to 1, the more accurate the prediction.

Lets now import essesntial librabies.

```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
import warnings
warnings.filterwarnings("ignore")
```

```python
def f_sigmoid(x, a, b, c):
        # c = sigmoid midpoint
        # b = curve steepness (logistic growth)
        # a = max value
        return (c / (1 + np.exp(b-a*(x))))
def Bertalanffy(x, a, b, c):
        # a = max value
        # b,c = function parameter
        return a*np.sign(1 - np.exp(-b*(x-x_0)))*np.abs(1 - np.exp(-b*(x)))**c
def Gompertz(x, a, b, c):
        # a = max value
        # b,c = function parameter
        return a*np.exp(-b*np.exp(-c*(x)))
def r2(y,y_hat):
    return 1-(np.sum((y-y_hat)**2)/np.sum((y - np.mean(y))**2))
```

We will be using John Hopkins dataset. Find it [here](https://github.com/CSSEGISandData/COVID-19).

First lets see how the data looks like.

```python
data = pd.read_csv('time_series_covid19_confirmed_global.csv')
data.head()
```

      Province/State
      Country/Region
      Lat
      Long
      1/22/20
      1/23/20
      1/24/20
      1/25/20
      1/26/20
      1/27/20
      ...
      5/8/20
      5/9/20
      5/10/20
      5/11/20
      5/12/20
      5/13/20
      5/14/20
      5/15/20
      5/16/20
      5/17/20

      0
      NaN
      Afghanistan
      33.0000
      65.0000
      0
      0
      0
      0
      0
      0
      ...
      3778
      4033
      4402
      4687
      4963
      5226
      5639
      6053
      6402
      6664

      1
      NaN
      Albania
      41.1533
      20.1683
      0
      0
      0
      0
      0
      0
      ...
      850
      856
      868
      872
      876
      880
      898
      916
      933
      946

      2
      NaN
      Algeria
      28.0339
      1.6596
      0
      0
      0
      0
      0
      0
      ...
      5369
      5558
      5723
      5891
      6067
      6253
      6442
      6629
      6821
      7019

      3
      NaN
      Andorra
      42.5063
      1.5218
      0
      0
      0
      0
      0
      0
      ...
      752
      754
      755
      755
      758
      760
      761
      761
      761
      761

      4
      NaN
      Angola
      -11.2027
      17.8739
      0
      0
      0
      0
      0
      0
      ...
      43
      43
      45
      45
      45
      45
      48
      48
      48
      48

5 rows × 121 columns

Dates start from 22nd January 2020. 'Country/Region' contains the country name and if province wise data is given then 'Province/State' contains those provinces.  Notice these are cumulative cases. Dates end on 17th May 2020. So, the total days will be 117.

```python
df=data[data['Country/Region']=='India']
cases = np.array(df.sum(axis = 0))[4:]
cases = cases.T.reshape(cases.size)
date  = np.linspace(1,cases.size,cases.size)
print(cases)
print("Number of days",cases.size)
plt.scatter(date,cases,label="India")
plt.xlabel("Dates from 22/1/2020")
plt.ylabel("Total Cases")
plt.legend()
```

The curve shows an upward growth. The curve obviously cannot just keep growing as some peak will be reached once either the infection is controlled or there are no new people to infect. It seems obvious to fit the data on some modified logistic curve.

We will be using the 'curve_fit' method of Scipy. We would plug our growth functions in the curve_fit method, which would return the optimum parameters for the given function.

In extra_date, I provide the dates up to which I would like my model to predict values. We would use the optimum parameters obtained to predict future values.

```python
#Predict upto 200 days
extra_date = np.linspace(1,200,200)

#Logistic
popt_sig, pcov_sig = curve_fit(f_sigmoid, date, cases)
ans1 = f_sigmoid(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])
#print(popt_sig)

#Bertalanffy
popt_sig, pcov_sig = curve_fit(Bertalanffy, date, cases)
ans2 = Bertalanffy(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])
#print(popt_sig)

#Gompertz
popt_sig, pcov_sig = curve_fit(Gompertz, date, cases)
ans3 = Gompertz(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])
#print(popt_sig)

plt.plot(date, cases,'.',color ='yellow', label ="data")
plt.plot(extra_date, ans1, '--', color ='blue', label ="sigmoid")
plt.plot(extra_date, ans2, '--', color ='green', label ="Bertalanffy")
plt.plot(extra_date, ans3, '--', color ='red', label ="Gompertz")
plt.xlabel("Dates from 22/1/2020")
plt.ylabel("Total Cases India")
plt.legend()
plt.show()
print("R2 score",r2(cases,ans1[:cases.size]))
```

Ok, so the graph shows two different types of curves. Sigmoid shows a very abrupt stop contrary to the other two curves. The two curves show a rapid increase in the cases. To see why this might be happening, look at the previous graph of total cases. the graph shows a rapid increase in new cases, as the data points are more spread out in later days. In the cycle of infection spread, this phase is known as the exponential growth phase. The R2 score gives us a nice value close to 1.

This phase is characterized by an exponential increase. The growth only stops if the infection is either controlled by a vaccine or quarantine measure or if there is no population left to infect. From the above graph, it can then be deduced that both Bertalanffy and Gompertz curves were able to utilize this fact to show the future trend of the infection. We will test this claim against different countries and also how close these predictions are to the actual values.

Let's see how this compares to other countries.

```python
df=np.array(data[data['Country/Region']=='India'])
cases = df[:,4:]
cases_ind = cases.T.reshape(cases.size)

df=np.array(data[data['Country/Region']=='Italy'])
cases = df[:,4:]
cases_it = cases.T.reshape(cases.size)

df=np.array(data[data['Country/Region']=='Iran'])
cases = df[:,4:]
cases_iran = cases.T.reshape(cases.size)

df=np.array(data[data['Country/Region']=='Switzerland'])
cases = df[:,4:]
cases_swiss = cases.T.reshape(cases.size)

plt.plot(date,cases_iran,label="Iran")
plt.plot(date,cases_swiss,label="Switzerland")
plt.plot(date,cases_ind,label="India")
plt.plot(date,cases_it,label="Italy")
plt.xlabel("Dates from 22/1/2020")
plt.ylabel("Confirmed cases")
plt.legend()
```

I have chosen these countries because they all are at different phases of infection. Switzerland has long achieved its peak. Iran is seeing another surge of increase in cases. Ilaty is about to achieve its peak. Lets now see how these the prediction graph looks like. For this, I will only use the Gompertz function.

```python
extra_date = np.linspace(1,150,150)
popt_sig, pcov_sig = curve_fit(Gompertz, date, cases_ind)
ans1 = Gompertz(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])

popt_sig, pcov_sig = curve_fit(Gompertz, date, cases_it)
ans2 = Gompertz(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])

popt_sig, pcov_sig = curve_fit(Gompertz, date, cases_iran)
ans3 = Gompertz(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])

popt_sig, pcov_sig = curve_fit(Gompertz, date, cases_swiss)
ans4 = Gompertz(extra_date,popt_sig[0],popt_sig[1],popt_sig[2])

plt.plot(date, cases_ind,'.',color ='yellow')
plt.plot(extra_date, ans1, color ='green', label ="India")

plt.plot(date, cases_it,'.',color ='yellow')
plt.plot(extra_date, ans2, color ='red', label ="Italy")

plt.plot(date, cases_swiss,'.',color ='yellow')
plt.plot(extra_date, ans4, color ='orange', label ="Switzerland")

plt.plot(date, cases_iran,'.',color ='yellow')
plt.plot(extra_date, ans3,color ='blue', label ="Iran")
plt.xlabel("Dates from 22/1/2020")
plt.ylabel("Confirmed cases")
plt.legend()
plt.show()
```

Except for India, the other countries will reach their peak in a couple of weeks. We can observe that India has a longer growth phase compared to other countries. Thus a longer growth phase in time, in turn, will correlate to a higher peak value. Also, due to lockdown measures, India's witnessed the delayed growth phase.

Lets now see how close these predictions are to real value. For this I create an iteration starting from the 50th date, I find the prediction for next +n date. What it means is that for each $i^{th}$ date i will predict for $i+n^{th}$ date. After this, we can see the difference between the actual and predicted values.

```python
sigmoid = pd.DataFrame(columns=['date','real','pred'])
n=2
for i in range(50,cases.size-n):
    extra_date = np.linspace(1,500,500)
    try:
        popt_sig, pcov_sig = curve_fit(f_sigmoid, date[:i], cases[:i])
        ans1 = (popt_sig[2] / (1 + np.exp(popt_sig[1]-(extra_date*popt_sig[0]))))
        sigmoid = sigmoid.append(pd.DataFrame({'date':[i],'real':[cases[i+n]],'pred':[ans1[i+n]]}))
    except RuntimeError:
        sigmoid = sigmoid.append(pd.DataFrame({'date':[i],'real':[cases[i+n]],'pred':[cases[i+n]]}))
        continue
plt.xlabel("Dates from 10/3/2020")
plt.title("n=2,sigmoid fuction")
plt.plot(np.array(sigmoid)[:,0], np.array(sigmoid)[:,1],'.',color ='red', label ="Actual data")
plt.plot(np.array(sigmoid)[:,0], np.array(sigmoid)[:,2],'.',color ='green', label ="Predicted Data")
plt.legend()
plt.show()
```

Here, the difference between the actual and predicted value is very small. Prediction of close dates can be done with high accuracy. The measure of difference can be computing the percentage error by taking the difference of actual and prediction values and dividing it by actual value.

Notice that I have used the try, except statement here. This is because the curve_fit method, in some cases fails to find the optimum parameters for the given iterations. Thus the function throws a RuntimeError which is taken by the except block. To keep the consistency of the graph, when the method fails to optimize the data, I substitute the actual values in place of the predicted values. This will give us a way to see such points in the graph.

```python
np.mean((np.array(sigmoid)[:,1] - np.array(sigmoid)[:,2])/np.array(sigmoid)[:,1])
```

    0.07855719960874576

7.8% error.

```python
bertalanffy = pd.DataFrame(columns=['date','real','pred'])
n=8
for i in range(50,cases.size-n):
    extra_date = np.linspace(1,500,500)
    try:
        popt_sig, pcov_sig = curve_fit(Bertalanffy, date[:i], cases[:i])
        ans2 = popt_sig[0]*(1 - np.exp(-popt_sig[1]*extra_date))**popt_sig[2]
        bertalanffy = bertalanffy.append(pd.DataFrame({'date':[i],'real':[cases[i+n]],'pred':[ans2[i+n]]}))
    except RuntimeError:
        bertalanffy = bertalanffy.append(pd.DataFrame({'date':[i],'real':[cases[i+n]],'pred':[cases[i+n]]}))
        continue
plt.title("n=8,bertalanffy fuction")
plt.plot(np.array(bertalanffy)[:,0], np.array(bertalanffy)[:,1],'.',color ='red', label ="Actual Data")
plt.plot(np.array(bertalanffy)[:,0], np.array(bertalanffy)[:,2],'.',color ='green', label ="Predicted Data")
plt.legend()
plt.show()
```

```python
np.mean((np.array(bertalanffy)[:,1] - np.array(bertalanffy)[:,2])/np.array(bertalanffy)[:,1])
```

    0.14557860820569543

```python
gompertz = pd.DataFrame(columns=['date','real','pred'])
n=15
for i in range(50,cases.size-n):
    extra_date = np.linspace(1,300,300)
    try:
        popt_sig, pcov_sig = curve_fit(Gompertz, date[:i], cases[:i])
        ans3 = popt_sig[0]*np.exp(-popt_sig[1]*np.exp(-popt_sig[2]*extra_date))
        gompertz = gompertz.append(pd.DataFrame({'date':[i],'real':[cases[i+n]],'pred':[ans3[i+n]]}))
    except RuntimeError:
        gompertz = gompertz.append(pd.DataFrame({'date':[i],'real':[cases[i+n]],'pred':[cases[i+n]]}))
        continue
plt.title("n=15, gompertz fuction")
plt.plot(np.array(gompertz)[:,0], np.array(gompertz)[:,1],'.',color ='red', label ="Actual Data")
plt.plot(np.array(gompertz)[:,0], np.array(gompertz)[:,2],'.',color ='green', label ="Predicted Data")
plt.legend()
plt.show()
```

```python
np.mean((np.array(gompertz)[:,1] - np.array(gompertz)[:,2])/np.array(gompertz)[:,1])
```

    0.2035440918563918

This gives us a 20% error. The error rate will be higher as for a lot of points the model failed to optimize. For days more than 10~20 models can give an approx prediction. However, in almost every case, the actual values are less than the predicted values. Thus our model can certainly give a conservative estimate of the epidemic for days more than 15.

## Conclusion
From the prediction graphs we also conclude that our model has been able to fit the virus spread at all stages. Different functions reacted differently to the data. We saw that sigmoid was not able to provide a good generalization as it was capping very soon. In contrast, Bertalanffy and Gompertz gave a better result. In between these two also, Gompertz gave a more conservative result. Prediction of the peak with these functions can only give a rough estimate as predicting for more than 20 days gave an approx 20% error.

In Conclusion, given the large number of factors that could influence the future cases in reality, thus predictions for more than 10-15 days become very difficult. Almost always, factors such as government policies, climate, lockdown measures, virus mutation, availability of the vaccine, etc, can greatly influence the number of cases. If these factors remain constant and the same as they were for the past, then our model can give a good prediction for more than 20 days. Creating an online model that updates the prediction every day will also give a better estimate.

```python

```