# Superdense coding

Alice has 2 classical bits she wants to send to Bob. Without quantum computing, Alice would have to send Bob 2 bits, one per piece information. But in fact, it is possible to send both bits using just 1 qubit!

Like many protocols in quantum computing, the key is making use of the entangled state
\begin{equation}
 |\Psi\rangle = \frac{1}{\sqrt{2}} \left( |00\rangle + |11 \rangle \right)
\end{equation}

We will see that if Alice and Bob each have a qubit of this entangled state, Alice can manipulate her qubit such that Bob is able to obtain both bits.

```python
import numpy as np

from qiskit import QuantumRegister, ClassicalRegister
from qiskit import QuantumCircuit
from qiskit import execute, BasicAer

import qiskit.tools.visualization as qvis

import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
```

Let's give Alice two bits at random.

```python
alice_bits = [int(x) for x in np.round(np.random.rand(2))]
print(f"Alice wants to send Bob the bits {alice_bits[0]} and {alice_bits[1]}.")
```

    Alice wants to send Bob the bits 0 and 1.

First, let's set up the shared entangled state.

```python
# One qubit each for Alice and Bob
alice = QuantumRegister(1, 'a')
bob = QuantumRegister(1, 'b')

# Bob needs to measure his qubits, so this is a classical register to store results
bob_outputs = ClassicalRegister(2, 'm')

# Initialize a quantum circuit
superdense_circuit = QuantumCircuit(alice, bob, bob_outputs)

# Create the entangled state
superdense_circuit.h(alice[0])
superdense_circuit.cx(alice[0], bob[0]);

superdense_circuit.draw()
```

        ┌───┐
a_0: |0>┤ H ├──■──
        └───┘┌─┴─┐
b_0: |0>─────┤ X ├
             └───┘
 m_0: 0 ══════════

 m_1: 0 ══════════

Now, based on Alice's bits, she's going to perform one of the following operations to her qubit:
\begin{eqnarray}
 00 &\rightarrow& I \\
 01 &\rightarrow& X \\
 10 &\rightarrow& Z \\
 11 &\rightarrow& ZX \\
\end{eqnarray}

```python
if alice_bits[0] == 0 and alice_bits[1] == 1:
    superdense_circuit.x(alice[0])
    print("Alice applies the X gate.")
elif alice_bits[0] == 1 and alice_bits[1] == 0:
    superdense_circuit.z(alice[0])
    print("Alice applies the Z gate.")
elif alice_bits[0] == 1 and alice_bits[1] == 1:
    superdense_circuit.x(alice[0])
    superdense_circuit.z(alice[0])
    print("Alice applies the X gate followed by the Z gate.")
else:
    print("Alice does nothing else to her qubits.")

superdense_circuit.draw()
```

    Alice applies the X gate.

        ┌───┐     ┌───┐
a_0: |0>┤ H ├──■──┤ X ├
        └───┘┌─┴─┐└───┘
b_0: |0>─────┤ X ├─────
             └───┘
 m_0: 0 ═══════════════

 m_1: 0 ═══════════════

You might notice that this looks suspiciously similar to the circuits we made in notebook 01-03, where we constructed all four Bell states - this is exactly what Alice has done! She has performed a basis change; based on her first two bits, the two qubits are now in the state

\begin{eqnarray}
 00 &\rightarrow& \frac{1}{\sqrt{2}} \left( |00\rangle + |11 \rangle \right) \\
 01 &\rightarrow& \frac{1}{\sqrt{2}} \left( |10\rangle + |01 \rangle \right) \\
 10 &\rightarrow& \frac{1}{\sqrt{2}} \left( |00\rangle - |11 \rangle \right) \\
 11 &\rightarrow& \frac{1}{\sqrt{2}} \left( |01\rangle - |10 \rangle \right)
\end{eqnarray}

Alice proceeds to send her qubit to Bob. With both qubits in hand, Bob has to make a measurement in the Bell basis. Depending on which of the four states he gets, he will know exactly which bits Alice sent.

Note: Qiskit doesn't have an implementation for measuring directly in the Bell basis, so we will need to do a basis change to get us back to the computational basis. This is just a CNOT from qubit 1 to 2 and then a Hadamard on qubit 1.

```python
# Basis change from Bell basis to computational basis
superdense_circuit.cx(alice[0], bob[0]) # Technically Bob now has both qubits
superdense_circuit.h(alice[0]);

superdense_circuit.draw()
```

        ┌───┐     ┌───┐     ┌───┐
a_0: |0>┤ H ├──■──┤ X ├──■──┤ H ├
        └───┘┌─┴─┐└───┘┌─┴─┐└───┘
b_0: |0>─────┤ X ├─────┤ X ├─────
             └───┘     └───┘
 m_0: 0 ═════════════════════════

 m_1: 0 ═════════════════════════

Now let's measure Bob's qubits.

```python
superdense_circuit.measure(alice[0], bob_outputs[0])
superdense_circuit.measure(bob[0], bob_outputs[1]);

superdense_circuit.draw()
```

        ┌───┐     ┌───┐     ┌───┐┌─┐
a_0: |0>┤ H ├──■──┤ X ├──■──┤ H ├┤M├
        └───┘┌─┴─┐└───┘┌─┴─┐└┬─┬┘└╥┘
b_0: |0>─────┤ X ├─────┤ X ├─┤M├──╫─
             └───┘     └───┘ └╥┘  ║
 m_0: 0 ══════════════════════╬═══╩═
                              ║
 m_1: 0 ══════════════════════╩═════

We'll run the circuit only a single time - we should get the answer right away...

Note: registers in Qiskit circuits are ordered in 'reverse'. So when we measure, we are actually getting something like $... b_2 b_1 b_0$. To make sure the bits are ordered correctly with respect to the problem, then, we have to reverse the output of the result.

```python
# Execute the quantum circuit
backend = BasicAer.get_backend('qasm_simulator')
result = execute(superdense_circuit, backend, shots = 1).result()
counts = result.get_counts()

# Counts is a dictionary and the keys are bit strings; take the first one and split
bob_bits = list(list(counts.keys())[0])
bob_bits.reverse()
```

```python
print(f"Alice sent Bob bits {alice_bits[0]} and {alice_bits[1]}.")
print(f"Bob received bits {bob_bits[0]} and {bob_bits[1]} from Alice")
```

    Alice sent Bob bits 0 and 1.
    Bob received bits 0 and 1 from Alice


# Finance Basics for Pandas

#### How to compound returns

Let $r_t = (P_t+D_t)/P_{t-1}$ be the simple return for a single period with dividends included.

Let $R_t = (1+r_t)$ be the **gross** (thus, the capital "R") return for a single period.

Then the gross return for a number of periods is

\begin{equation}
R[0,T] = \prod_{t=1}^T R_t
\end{equation}

and the return for the same set of periods is simply the above minus 1.

\begin{align}
r[0,T] = & R[0,T] - 1  \\
= & (\prod_{t=1}^T R_t) - 1
\end{align}

There is one more way to compute the gross return: "Sum the log returns, then exponentiate":

\begin{align}
R[0,T] = & \prod_{t=1}^T R_t \\
       = & \prod_{t=1}^T ( e(ln( R_t))        ) \\
       = & e(\sum_{t=1}^T ln(R_t)) \\
\end{align}
the second line works because $e(ln(x))=x$ and the last line works because $e^a*e^b*e^c=e^{a+b+c}$.

```python
(df
 .groupby(['asset','tiemperiod'])
 .agg(ret = lambda x: ((1+x['ret']).prod())-1 )
```


# Content
* Libraries & Contants
* Introduction to Problem
    * Loading Dataset
    * Visualizing Raw Dataset
    * Preprocessing
    * Visualizing Proprocessed Dataset
* Logistic Regression
    * Forward Propagation
    * Backward Propagation
    * Complete Propagation
    * Combining All Together
* Training
* Prediction
* Evaluation

# Libraries & Contants

```python
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sb
import pandas as pd
from sklearn import datasets
from utils import plot_prediction
```

# Introduction to Problem
Given a set of inputs X as features of flowers, we want to assign them to one of two possible categories 0 or 1 that means what type of flower they are.
For solving this problem we use logistic regression because it models the probability that each input belongs to a particular category.

## Loading Dataset

```python
iris = pd.read_csv('./data/Iris.csv')
iris.head()
```

      Id
      SepalLengthCm
      SepalWidthCm
      PetalLengthCm
      PetalWidthCm
      Species

      0
      1
      5.1
      3.5
      1.4
      0.2
      Iris-setosa

      1
      2
      4.9
      3.0
      1.4
      0.2
      Iris-setosa

      2
      3
      4.7
      3.2
      1.3
      0.2
      Iris-setosa

      3
      4
      4.6
      3.1
      1.5
      0.2
      Iris-setosa

      4
      5
      5.0
      3.6
      1.4
      0.2
      Iris-setosa

## Visualizing Dataset

```python
sepalPlt = sb.FacetGrid(iris, hue="Species", size=6).map(plt.scatter, "SepalLengthCm", "SepalWidthCm")
plt.legend(loc='upper left');
```

# Preprocessing

In this step we will simplify the problem into a binary classification problem with just 2 features.

```python
X = iris.iloc[:, 1:3]
Y = (iris['Species'] != 'Iris-setosa') * 1
```

```python
plt.figure(figsize=(10, 6))
plt.scatter(X[Y == 0].iloc[:, 0], X[Y == 0].iloc[:, 1], color='b', label='Iris-setosa')
plt.scatter(X[Y == 1].iloc[:, 0], X[Y == 1].iloc[:, 1], color='r', label='Others')
plt.xlabel('SepalLengthCm')
plt.ylabel('SepalWidthCm')
plt.legend()
```

# Logistic Regression
  Logistic Regression is a Machine Learning classification algorithm that is used to predict the probability of a categorical dependent variable. In logistic regression, the dependent variable is a binary variable that contains data coded as 1 (yes, success, etc.) or 0 (no, failure, etc.). In other words, the logistic regression model predicts P(Y=1) as a function of X.

## Forward Propagation

First we will implement linear multiplication:

\begin{align}
Z = W^TX + b
\end{align}

```python
def linear_mult(X, w, b):
    return np.dot(w.T, X) + b
```

Now we implement a function could generate W and b for us:

```python
def initialize_with_zeros(dim):
    """
    This function creates a vector of zeros of shape (dim, 1) for w and initializes b to 0.

    Argument:
    dim -- size of the w vector we want (or number of parameters in this case)

    Returns:
    w -- initialized vector of shape (dim, 1)
    b -- initialized scalar (corresponds to the bias)
    """

    w = np.zeros((dim,1))
    b = 0

    assert(w.shape == (dim, 1))
    assert(isinstance(b, float) or isinstance(b, int))

    return w, b
```

Next we will implement sigmoid function to map calculated value to a probablity:

\begin{align}
\sigma(Z) = \frac{1}{1 + e^{-Z}}
\end{align}

```python
def sigmoid(z):
    return 1 / (1 + np.exp(-z))
```

Now we implement the cost function, cost function represent the difference between our predictions and actual labels(y is the actual label and a is our predicted label):

\begin{align}
J = -\frac{1}{m}\sum_{i=1}^{m}y^{(i)}\log(a^{(i)})+(1-y^{(i)})\log(1-a^{(i)})
\end{align}

```python
def cost_function(y, a):
    return -np.mean(y*np.log(a) + (1-y)*np.log(1-a))
```

Now we implement the whole forward propagation which will calculate cost and the predicted value for the each data point:

```python
def forward_propagate(w, b, X, Y):

    m = X.shape[1]

    Z = linear_mult(X, w, b)
    A = sigmoid(Z)
    cost = cost_function(Y, A)
    cost = np.squeeze(cost)

    assert(cost.shape == ())

    back_require = {
        'A': A
    }

    return back_require, cost
```

## Backward Propagation

Now we calculate W and b derivative as follow:

$$ \frac{\partial J}{\partial w} = \frac{1}{m}X(A-Y)^T\tag{7}$$
$$ \frac{\partial J}{\partial b} = \frac{1}{m} \sum_{i=1}^m (a^{(i)}-y^{(i)})\tag{8}$$

```python
def backward_propagate(w, b, X, Y, back_require):

    m = X.shape[1]

    A = back_require['A']

    dw = (1/m) * np.dot(X,(A-Y).T)
    db = (1/m) * np.sum(A - Y)

    assert(dw.shape == w.shape)
    assert(db.dtype == float)

    grads = {"dw": dw,
             "db": db}

    return grads
```

# Complete Propagation

```python
def propagate(w, b, X, Y):
    """
    Implement the cost function and its gradient for the propagation explained above

    Arguments:
    w -- weights, a numpy array of size (num_px * num_px * 3, 1)
    b -- bias, a scalar
    X -- data of size (num_px * num_px * 3, number of examples)
    Y -- true "label" vector (containing 0 if non-cat, 1 if cat) of size (1, number of examples)

    Return:
    cost -- negative log-likelihood cost for logistic regression
    dw -- gradient of the loss with respect to w, thus same shape as w
    db -- gradient of the loss with respect to b, thus same shape as b

    Tips:
    - Write your code step by step for the propagation. np.log(), np.dot()
    """

    # FORWARD PROPAGATION
    back_require, cost = forward_propagate(w, b, X, Y)

    # BACKWARD PROPAGATION
    grads = backward_propagate(w, b, X, Y, back_require)

    return grads, cost
```

# Combining All Together

Now we combine all our implemented functions together to create an optimizer which can find a linear function to devide the zero labeled data from one labeled data points by optimizng W and b as follow:
$$W=W−\alpha{dw}$$
$$b=b−\alpha{db}$$
$\alpha$ is the learning rate

```python
# GRADED FUNCTION: optimize

def optimize(w, b, X, Y, num_iterations, learning_rate, print_cost = False):
    """
    This function optimizes w and b by running a gradient descent algorithm

    Arguments:
    w -- weights, a numpy array of size (num_px * num_px * 3, 1)
    b -- bias, a scalar
    X -- data of shape (num_px * num_px * 3, number of examples)
    Y -- true "label" vector (containing 0 if non-cat, 1 if cat), of shape (1, number of examples)
    num_iterations -- number of iterations of the optimization loop
    learning_rate -- learning rate of the gradient descent update rule
    print_cost -- True to print the loss every 100 steps

    Returns:
    params -- dictionary containing the weights w and bias b
    grads -- dictionary containing the gradients of the weights and bias with respect to the cost function
    costs -- list of all the costs computed during the optimization, this will be used to plot the learning curve.

    Tips:
    You basically need to write down two steps and iterate through them:
        1) Calculate the cost and the gradient for the current parameters. Use propagate().
        2) Update the parameters using gradient descent rule for w and b.
    """

    costs = []

    for i in range(num_iterations):

        grads, cost = propagate(w,b,X,Y)

        dw = grads["dw"]
        db = grads["db"]

        w -= learning_rate*dw
        b -= learning_rate*db

        # Record the costs
        if i % 100 == 0:
            costs.append(cost)

        # Print the cost every 100 training iterations
        if print_cost and i % 100 == 0:
            print ("Cost after iteration %i: %f" %(i, cost))

    params = {"w": w,
              "b": b}

    grads = {"dw": dw,
             "db": db}

    return params, grads, costs
```

# Training

```python
X_t, Y_t = np.array(X.T), np.array(Y.T)
w, b = initialize_with_zeros(2)
params, grads, costs = optimize(w, b, X_t, Y_t, num_iterations= 5000, learning_rate = 0.009, print_cost = False)
```

```python
plt.plot(range(len(costs)),costs)
plt.xlabel('Iterations')
plt.ylabel('Cost(loss) value')
```

# Prediction

```python
def predict(w, b, X):
    '''
    Predict whether the label is 0 or 1 using learned logistic regression parameters (w, b)

    Arguments:
    w -- weights, a numpy array of size (num_px * num_px * 3, 1)
    b -- bias, a scalar
    X -- data of size (num_px * num_px * 3, number of examples)

    Returns:
    Y_prediction -- a numpy array (vector) containing all predictions (0/1) for the examples in X
    '''

    m = X.shape[1]
    Y_prediction = np.zeros((1,m))

    Z = linear_mult(X, w, b)
    A = sigmoid(Z)

    for i in range(m):

        Y_prediction[0][i] = 1 if A[0][i] > .5 else 0

    assert(Y_prediction.shape == (1, m))

    return Y_prediction
```

# Evaluation

```python
preds = predict(params['w'], params['b'], X_t)
print('Accuracy on training set: %{}'.format((preds[0] == Y).mean()*100))
```

    Accuracy on training set: %99.33333333333333

```python
plot_prediction(X, Y, predict, params)
```

Week 7 - Logistic Regression/Logistic Regression.ipynb


# PC lab 10: Artificial Neural Networks
---

## Introduction

Although artificial neural networks enjoy a lot of progress today, they were first described by [Warren McCulloch and Walter Pitts](https://link.springer.com/article/10.1007%2FBF02478259) in 1943. Early progress in training competitive neural networks was stalled by a multitude of reasons, such as the limited computer resources, sub-optimal network architectures and the use of smaller datasets. In this PC-lab we will implement a custom neural network on a step-by-step basis, allowing an in-depth comprehension of the essential elements of deep learning

### Artificial Neuron

The core unit of every (artificial) neural network is considered the neuron. The neuron can be observed as a switch. It receives **one or more inputs** $\mathbf{x}$, processes a **weighted sum** $z$ (adding **bias** $b$) that is sent through the **sigmoid activation function $\sigma()$**, outputing a **single response**  $a$:

$$
z = \sum\limits_{i=1}^{n}(w_ix_i) + b = \sum\limits_{i=0}^{n}(w_ix_i)$$ with $$ x_0 = 1 \tag{1}\\
$$

$$ a = \sigma(z) $$

The default recommended activation function is the **Rectified Linear Unit**, or **ReLU**.

$$ ReLU(z) = max\{0,z\} $$

The ReLU function has many properties that make optimization easy using gradient-based methods. It can be seen as a switch giving no response for $z < 0$ and giving a response $z$ for $z > 0$.

### Artificial Neural Networks

The most basic artificial neural network is the **feedforward neural network**. There are no feedback connections such as can be found in **recurrent neural networks**. A feedforward neural network is called a network as it is composed out of many inheriting functions making up the model, e.g. $f(\textbf{x})= f^{(3)}(f^{(2)}(f^{(1)}(\textbf{x})))$. Neural networks typically are constructed in different layers of neurons in which every neuron is connected with all the neurons of the previous layer, eventually resulting in a set of **output neurons** $\mathbf{\hat{\textbf{y}}}$.

To train the network, samples are processed in batches. This allows for faster training and improved convergence of the loss during gradient descent. Advantages of stochastic gradient descent or other optimization algorithms for loss calculation are not discussed in this PC-lab, but have been [extensively discussed](https://ruder.io/optimizing-gradient-descent/) before.
Practically, the first fully-connected layer of the network using batch size $B$ is computed by matrix combination of the input $X \in \mathbb{R}^{B, D}$ with a set of weights $W^{(12)} \in \mathbb{R}^{D, M}$.

\begin{equation}
XW^{(12)} =
\begin{bmatrix}
1 & x_{0,1} & ...  & x_{0,D-1} & x_{0,D} \\
1 & x_{1,1} & ... & x_{1,D-1} & x_{1,D} \\
... & ... & ... & ... & ...\\
1 & x_{B-1,1} & ... & x_{B-1,D-1} & x_{B-1,D} \\
1 & x_{B,1} & ...  & x_{B,D-1} & x_{B,D} \\
\end{bmatrix}
\begin{bmatrix}
W_{0,0} & W_{0,1} & ...  & W_{0,M-1} & W_{0,M} \\
W_{1,0} & W_{1,1} & ... & W_{1,M-1} & W_{1,M} \\
... & ... & ... & ... & ...\\
W_{D-1,0} & W_{D-1,1} & ... & W_{D-1,M-1} & W_{D-1,M} \\
W_{D,0} & W_{D,1} & ...  & W_{D,M-1} & W_{D,M} \\
\end{bmatrix}
\end{equation}

Structure of the exercise

The idea of this PC-Lab will be to construct our very own neural network from scratch. Every exercize will introduce a new feature necessary to train a working model.

We define the class Neural_Network for which the structural architecture is given at initialization. Let's start by creating a neural network with one hidden layer. To include the bias term, we add a scalar to the weights vector for every layer.

    class Neural_Network(object):
    def __init__(self, input_nodes, output_nodes, hlayer_nodes):
        self.input_nodes = input_nodes
        self.output_nodes = output_nodes
        self.hlayer_nodes = hlayer_nodes
        # initialize weights + bias in the first layer
        self.W_12 = np.random.randn(self.input_nodes+1, self.hlayer_nodes))
        # initialize weights + bias in the second layer
        self.W_23 = append(np.random.randn(self.hlayer_nodes+1, self.output_nodes))

EXERCISE

Complete the attribute function Neural_Net.forward() and Neural_Net.relu()

|Code Symbol | Math Symbol | Definition | Dimensions
| :-: | :-: | :-: | :-: |
|X|$$X$$|Input Data| (batch_size, input_nodes) |
|y|$$y$$|label| (batch_size, output_nodes) |
|y_hat| $$\hat{y}$$| Output Data | (batch_size, output_nodes) |
|W_12 | $$W^{(12)}$$ | Layer 1 weights | (input_nodes + 1, hlayer_nodes) |
|W_23 | $$W^{(23)}$$ | Layer 2 weights | (hlayer_nodes + 1, output_nodes) |
|a_1 | $$a^{(1)}$$ | Layer 1 activity (inputs) | (batch_size, input_nodes + 1) |
|z_2 | $$z^{(2)}$$ | Layer 2 linear combination | (batch_size, hlayer_nodes) |
|a_2 | $$a^{(2)}$$ | Layer 2 activity | (batch_size, hlayer_nodes + 1) |
| y_hat | $\hat{y}$ | Layer 3 linear combination | (batch_size, output_nodes) |

$$
\textbf{z}^{(2)} = \textbf{a}^{(1)}\textbf{W}^{(12)}  \tag{2}\\
$$
$$
\textbf{a}^{(2)} = ReLU(\textbf{z}^{(2)}) \tag{3}\\
$$
$$
\hat{\textbf{y}} = \textbf{a}^{(2)}\textbf{W}^{(23)} \tag{4}\\
$$

```python
import numpy as np
```

```python
class Neural_Network(object):
    def __init__(self, input_nodes, output_nodes, hlayer_nodes):
        self.input_nodes = input_nodes
        self.output_nodes = output_nodes
        self.hlayer_nodes = hlayer_nodes

        # initialize weights + bias in the first layer
        self.W_12 = np.random.randn(self.input_nodes+1, self.hlayer_nodes)
        # initialize weights + bias in the second layer
        self.W_23 = np.random.randn(self.hlayer_nodes+1, self.output_nodes)

    def forward(self, X):
        # add bias to input samples
        self.a_1 = np.hstack((np.ones((len(X),1)), X))

        self.z_2 = """..."""
        self.a_2 = """..."""
        self.y_hat = """..."""

        return np.clip(self.y_hat, -10e6, 10e6)

    def relu(self, z):
        """..."""

```

```python
np.random.seed(1)
# Initialize neural network with 1 input, 1 output and 2 nodes in hidden layer
NN = Neural_Network(1,1,3)
# Input two samples, each with one input
NN.forward([[2],[3]])
```

    array([[2.10183529],
           [2.37793415]])

## Backpropagation

Let's construct a dataset that follows the function $\mathbf{y = sin(x)}$. To train the neural network we have to evaluate how the prediction $\hat{y}$ compares to the true label $y$. This is done through the loss function $L$. The formula used for L is dependent upon the problem we are trying to solve. In line with previous methods fitting a **regression** problem, we consider the **MSE** :

$$
L = \frac{1}{n}\sum_{i=1}^{n}{(y_i-\hat{y_i})^2}\tag{6}
$$

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

def squared_error(y, y_hat):
    return (y-y_hat)**2

X = np.random.uniform(-3, 3, size=(1000,1)) # sample random points for x=[0, pi]
y = -26*X + 145*X**2 + 28*X**3 - 26*X**4 - 2*X**5 + X**6 - 80
#y = np.sum(np.sin(X),axis=1).reshape(-1,1) # sinus transform
NN = Neural_Network(1,1,40)
y_hat = NN.forward(X) # predict values
loss = squared_error(y, y_hat)

fig, (ax_1, ax_2, ax_3) = plt.subplots(1,3, figsize=(15,4))
ax_1.set_title("sample data")
ax_1.scatter(X, y)
ax_2.set_title("predictions on sample data")
ax_2.scatter(X, y_hat)
ax_3.set_title("loss function at each sample")
ax_3.scatter(X, loss)
```

To train our model, we must find a set of weights that minimizes the overall loss function. An exhaustive method would be to try all possible combinations of weights, a solution that blows up with increasing dimensionality. In order to reduce processing times to obtain the optimal set of weights, we evaluate the influence of each weight on the loss function. If we consider the different equations to obtain our loss:

$$
\textbf{z}^{(2)} = \textbf{a}^{(1)}\textbf{W}^{(12)} \tag{2}\\
$$
$$
\textbf{a}^{(2)} = ReLU(\textbf{z}^{(2)}) \tag{3}\\
$$
$$
\hat{\textbf{y}} = \textbf{a}^{(2)}\textbf{W}^{(23)} \tag{4}\\
$$
$$
L = \frac{1}{n} \sum_{i=0}^{n}{(y_{i}-\hat{y_{i}})^2} \tag{6}\\
$$

To evaluate the influence of $W^{(23)}$ on the loss $L$, we can find the **partial derivative** of the loss in function of $W^{(23)}$. Using the **chain rule**, this can be calculated using a step-by-step approach.
$$ \frac{\delta L}{\delta W^{(23)}} = \frac{\delta \textbf{L}}{\delta \hat{\textbf{y}}} \cdot \frac{\delta \hat{\textbf{y}}}{\delta \textbf{W}^{(23)}} = \delta_{1} \cdot \frac{\delta \hat{\textbf{y}}}{\delta \textbf{W}^{(23)}}  \tag{7}$$

Backpropagation is the step-by-step evaluation of the partial derivatives of the loss in function of the network weights, performed by backward iteration over the different transformations performed by the network. The values obtained when iterating down from the loss function are recyclable when calculating the derivative of weights further down in the chain rule, making **a substantial difference** in the amount of processing power needed to obtain all derivatives. The use of backpropagation is a major element in making training of deep neural networks possible. For example, to obtain the derivatives of the weights in the first layer ($\textbf{W}^{(12)}$) we apply:

$$ \frac{\delta L}{\delta W^{(12)}} = \delta_{1} \cdot \frac{\delta \hat{\textbf{y}}}{\delta \textbf{a}^{(2)}} \cdot \frac{\delta \textbf{a}^{(2)}}{\delta \textbf{z}^{(2)}} \cdot \frac{\delta \textbf{z}^{(2)}}{\delta \textbf{W}^{(12)}} = \delta_{2} \cdot \frac{\delta \textbf{z}^{(2)}}{\delta \textbf{W}^{(12)}} \tag{8} $$

$\delta_{1}$ and $\delta_{2}$ are solely introduced as variables to which the intermediate values can be assigned. Using these variables when implementing backpropagation into the neural network will make things easier.

EXERCISE:

Write out the following partial derivatives as found in the chain rule.

$$\frac{\delta L}{\delta \hat{y}}$$

$$\frac{\delta \hat{y}}{\delta W^{(23)}}$$

$$\frac{\delta \hat{y}}{\delta a^{(2)}}$$

$$\frac{\delta a^{(2)}}{\delta z^{(2)}}$$

$$\frac{\delta z^{(2)}}{\delta W^{(12)}}$$

EXERCISE

Complete the attribute functions NN.backpropagate() and NN.relu_prime(), and the function loss_prime. Use the partial derivatives of the previous exercise to get an overview of the sequential steps executed in backpropagation.

```python
# Replace with solution of NN here

class Neural_Network(object):
    def __init__(self, input_nodes, output_nodes, hlayer_nodes):
        self.input_nodes = input_nodes
        self.output_nodes = output_nodes
        self.hlayer_nodes = hlayer_nodes

        # initialize weights + bias in the first layer
        self.W_12 = np.random.randn(self.input_nodes+1, self.hlayer_nodes)
        # initialize weights + bias in the second layer
        self.W_23 = np.random.randn(self.hlayer_nodes+1, self.output_nodes)

    def forward(self, X):
        # add bias to input samples
        self.a_1 = np.hstack((np.ones((len(X),1)), X))

        """..."""

        return np.clip(self.y_hat, -10e6, 10e6)

    def relu(self, z):

        """..."""

    def relu_prime(self, z):

        """..."""

    def backpropagate(self, X, y, get_loss_prime):
        self.y_hat = """..."""

        self.delta_1 = """..."""
        self.dLdW_23 = """..."""
        self.dLda_2 = """..."""
        self.delta_2 = """..."""
        self.dLdW_12 = """..."""

        # join two flattened arrays in one vector
        return np.concatenate((self.dLdW_12.ravel(), self.dLdW_23.ravel()))

```

```python
def get_loss_prime(y_hat, y):
    """...""" # Cost function over all batches, divided by # batches

np.random.seed(1)
NN = Neural_Network(1,1,2)
NN.backpropagate(X[:2], y[:2], get_loss_prime)
```

    > <ipython-input-101-460f4000b56d>(36)backpropagate()
    -> self.y_hat = self.forward(X)
    (Pdb) c

    array([  387.7926547 , -1248.30602047, -1414.45232628,  3539.78947657,
             448.10403971,  1591.13799986,  1318.43267606])

EXERCISE:

Evaluate the values and shapes of dLdW_12 and dLdW_23 separately, and compare the size of vectors. Can you differentiate the updates for weights from the updates for the biases? Can you predict the shapes of these values after changing the amount of nodes of the different layers within the neural network? Don't forget to run NN.backpropagation() with updated arguments when changing the hyperparameters of the network!

```python
NN = Neural_Network(1,1,2)
NN.backpropagate([[1],[2]], [[np.sin(1)],[np.sin(2)]], get_loss_prime)
print("dLdW_12:\n {}".format(NN.dLdW_12))
print("dLdW_23:\n {}".format(NN.dLdW_23))

```

    dCdW_12:
     [[0. 0.]
     [0. 0.]]
    dCdW_23:
     [[-3.55219522]
     [ 0.        ]
     [ 0.        ]]

## Gradient Descent

Our simple neural network is close to completion now. One more important element is the ability to train this network. To adjust the weights based on the partial derivatives we need to apply the obtained gradient to perform an update to the actual weights. As we are dealing with a **non-convex** and **non-linear optimization problem**, updates are performed using the well-known **gradient descent** algorithm. Small variations on the gradient descent method have shown to drastically increase the speed at which deep neural networks can be trained (i.e. [momentum, adam, ...](http://ruder.io/optimizing-gradient-descent/index.html)).

### A brief recap: Gradient Descent

$$W_{t+1} = W_{t} - \eta \nabla L(\theta)$$

where

$\eta$: learning rate (default: 1e-4)

>**input** starting point $\textbf{x}\in$ **dom** $f$.
>
>**repeat**
>
>>    1. $\Delta \textbf{x} := -\nabla f(\textbf{x})$.
>>    3. *Update*. $\textbf{x}:=\textbf{x}+\eta\Delta \textbf{x}$.
>
>**until** stopping criterion is satisfied.

>**output** $x$

The stopping criterion is usually expressed as the amount of times the dataset is iterated. An **epoch** is one iteration over the whole dataset.

```python
from sklearn.utils import shuffle
```

```python
# Replace with solution of NN here

class Neural_Network(object):
    def __init__(self, input_nodes, output_nodes, hlayer_nodes):
        self.input_nodes = input_nodes
        self.output_nodes = output_nodes
        self.hlayer_nodes = hlayer_nodes

        # initialize weights + bias in the first layer
        self.W_12 = np.random.randn(self.input_nodes+1, self.hlayer_nodes)
        # initialize weights + bias in the second layer
        self.W_23 = np.random.randn(self.hlayer_nodes+1, self.output_nodes)

    def forward(self, X):
        # add bias to input samples
        self.a_1 = np.hstack((np.ones((len(X),1)), X))

        """..."""

        return np.clip(self.y_hat, -10e6, 10e6)

    def relu(self, z):

        """..."""

    def relu_prime(self, z):

        """..."""

    def backpropagate(self, X, y, get_loss_prime):
        self.y_hat = """..."""

        self.dLdW_23 = """..."""
        self.dLda_2 = """..."""
        self.delta_2 = """..."""
        self.dLdW_12 = """..."""

        # join two flattened arrays in one vector
        return np.concatenate((self.dLdW_12.ravel(), self.dLdW_23.ravel()))

    def set_weights(self, weights):
        self.W_12 = weights[:len(self.W_12.ravel())].reshape(self.W_12.shape)
        self.W_23 = weights[len(self.W_12.ravel()):].reshape(self.W_23.shape)

    def get_weights(self):
        return np.concatenate((self.W_12.ravel(), self.W_23.ravel()))

    def compile(self, optimizer_f, loss_f, loss_prime_f):
        self.optimizer =  optimizer_f
        self.get_loss = loss_f
        self.get_loss_prime = loss_prime_f

    def fit(self, X, y, batch_size=8, epochs=50, eta=1e-5):
        epoch = 0   # set starting epoch
        pool = np.arange(len(X)//batch_size)   # setup batch pool
        avg_loss_all = []   # initialize loss vector
        while epoch<epochs:
            print("\repoch: {}".format(epoch), end="")
            epoch +=1
            # shuffle X, y
            X_scrambled, y_scrambled = shuffle(X,y)
            avg_loss = 0
            for i in shuffle(pool):   # select random batch
                # select batch data
                X_batch = X_scrambled[i*batch_size:(i+1)*batch_size]
                y_batch = y_scrambled[i*batch_size:(i+1)*batch_size]
                # get prediction
                y_hat = self.forward(X_batch)
                # get loss on prediction
                avg_loss += self.get_loss(y_hat, y_batch)
                # get gradients on weights
                dLdW = self.backpropagate(X_batch, y_batch, self.get_loss_prime)
                # get update values with weight gradients
                dLdW_update = self.optimizer(dLdW, eta)
                self.set_weights(self.get_weights()+dLdW_update)   # set new weights with update values
            avg_loss_all.append(avg_loss)

        return avg_loss_all
```

EXERCISE

Complete the function gradient_descent(). The different steps as explained above have already been partially implemented in the Neural_Network.fit() function, make sure to analyze it before completing the code for the gradient_descent function. gradient_descent is one way to optimize your weights given a loss, and is stored in Neural_Network.optimizer when running Neural_Network.compile()

```python
def MSE_loss(y_hat, y):
    return """..."""

def MSE_loss_prime(y_hat, y):
    return """..."""

def gradient_descent(dLdW, eta):
    return """..."""
```

EXERCISE

Run the code below and evaluate how the network trains when tweaking with specific functions such as the amount of hidden nodes, epochs and step size of the gradient update.

```python
np.random.seed(1)
NN = Neural_Network(1,1,320)
NN.compile(gradient_descent, MSE_loss, MSE_loss_prime)

fig, ax = plt.subplots(2,2, figsize=(10,10))
ax_1, ax_2, ax_3, ax_4 = ax.ravel()
ax_1.scatter(X , NN.forward(X))
ax_1.set_title("Predicted y values BEFORE GD")
loss = NN.fit(X, y, epochs=200, batch_size=5, eta=1e-5)
ax_2.scatter(X, NN.forward(X))
ax_2.set_title("Predicted y values AFTER GD")
ax_3.scatter(X,y)
ax_3.set_title("True y values")
ax_4.set_title("Loss function throughout training")
ax_4.plot(range(len(loss)),loss, 'r')

```

Optional EXERCISE

Tweak the network to allow for classification purposes. For this, a sigmoid layer has to be added as the final step in
    NN.forward(). Make sure to adjust the backpropagate step and the loss function accordingly.

```python

```


# Task One : Fuandamentals of Qunatum Computation

## Basic Arithmatic Operations & Complex Numbers

```python
5+9
```

    14

```python
12*4
```

    48

Complex numbers are always of the form

\begin{align}
\alpha = a + bi
\end{align}

```python
1j * 1j
```

    (-1+0j)

```python
z = 3 + 4j
w = 1 - 2j
```

```python
import numpy as np
```

```python
print('z=', z)
print('Real(z)=', np.real(z))
print('Imag(z)=', np.imag(z))
```

    z= (3+4j)
    Real(z)= 3.0
    Imag(z)= 4.0

```python
z+w
```

    (4+2j)

```python
z-w
```

    (2+6j)

```python
z/w
```

    (-1+2j)

## Complex conjugate

```python
z
```

    (3+4j)

```python
np.conj(z)
```

    (3-4j)

## Norms/Absolute Values

\begin{align} ||z|| &= \sqrt{zz^*} = \sqrt{|z|^2},\\ ||w|| &= \sqrt{ww^*} = \sqrt{|w|^2}, \end{align}

```python
print('||z||=', np.abs(z))
```

    ||z||= 5.0

```python
print('||w||=', np.abs(w))
```

    ||w||= 2.23606797749979

## Row Vectors, Column Vectors, and Bra-Ket Notation

\begin{align} \text{Column Vector:} \ \begin{pmatrix}
a_1 \\ a_2 \\ \vdots \\ a_n
\end{pmatrix}
\quad \quad \text{Row Vector:} \ \begin{pmatrix}
a_1, & a_2, & \cdots, & a_n
\end{pmatrix} \end{align}

```python
# Create a vector as a row
row_vector = np.array([2-1j, 7j, -3])

# Create a vector as a column
column_vector = np.array([[2+1j],
                          [-5],
                          [2j]])
```

```python
row_vector
```

    array([ 2.-1.j,  0.+7.j, -3.+0.j])

Row vectors in quantum mechanics are also called **bra-vectors**, and are denoted as follows:

\begin{align} \langle A| = \begin{pmatrix}
a_1, & a_2, \cdots, & a_n
\end{pmatrix} \end{align}

Column vectors are also called **ket-vectors** in quantum mechanics denoted as follows:

\begin{align} |B\rangle = \begin{pmatrix}
b_1 \\ b_2 \\ \vdots \\ b_n
\end{pmatrix} \end{align}

In general, if we have a column vector, i.e. a ket-vector:

\begin{align} |A\rangle = \begin{pmatrix}
a_1 \\ a_2 \\ \vdots \\ a_n
\end{pmatrix} \end{align}

the corresponding bra-vector:

\begin{align} \langle A| = \begin{pmatrix}
a_1^*, & a_2^*, & \cdots, & a_n^*
\end{pmatrix} \end{align}

```python
ket_A = np.array([[2-1j],
                  [5]])
ket_B = np.array([[7],
                  [-3j]])
```

```python
print(ket_A + ket_B)
```

    [[9.-1.j]
     [5.-3.j]]

## Inner Product

\begin{align} \langle A| = \begin{pmatrix}
a_1, & a_2, & \cdots, & a_n
\end{pmatrix}, \quad \quad
|B\rangle = \begin{pmatrix}
b_1 \\ b_2 \\ \vdots \\ b_n
\end{pmatrix} \end{align}

Taking the inner product of $\langle A|$ and $|B\rangle$ gives the following:

\begin{align} \langle A| B \rangle &= \begin{pmatrix}
a_1, & a_2, & \cdots, & a_n
\end{pmatrix}
\begin{pmatrix}
b_1 \\ b_2 \\ \vdots \\ b_n
\end{pmatrix}\\
&=
a_1b_1 + a_2b_2 + \cdots + a_nb_n\\
&= \sum_{i=1}^n a_ib_i
\end{align}

```python
# Define the 4x1 matrix version of a column vector (instead of using the np.array() version):
A = np.matrix([[1-1j],
               [3],
               [2j],
               [5+1j]])
A
```

    matrix([[1.-1.j],
            [3.+0.j],
            [0.+2.j],
            [5.+1.j]])

```python
# Define B as a 1x4 matrix
B = np.matrix([[-3j, 2+2j, -6j, -7]])

B
```

    matrix([[-0.-3.j,  2.+2.j, -0.-6.j, -7.+0.j]])

```python
# Compute <B|A>
np.dot(B,A)
```

    matrix([[-20.-4.j]])

## Matrices

\begin{align}
M = \begin{pmatrix}
2-i & -3 \\
-5i & 2
\end{pmatrix}
\end{align}

```python
M = np.array([[2-1j, -3],
              [-5j, 2]])

print(M)
```

    [[ 2.-1.j -3.+0.j]
     [-0.-5.j  2.+0.j]]

```python
M = np.matrix([[2-1j, -3],
               [-5j, 2]])

print(M)
```

    [[ 2.-1.j -3.+0.j]
     [-0.-5.j  2.+0.j]]

Hermitian conjugates are given by taking the conjugate transpose of the matrix

```python
print(M.H)
```

    [[ 2.+1.j -0.+5.j]
     [-3.-0.j  2.-0.j]]

## Tensor Products of Matrices

\begin{align}
\begin{pmatrix}
a & b \\
c & d
\end{pmatrix} \otimes
\begin{pmatrix}
x & y \\
z & w
\end{pmatrix} =
\begin{pmatrix}
a \begin{pmatrix}
x & y \\
z & w
\end{pmatrix} & b \begin{pmatrix}
x & y \\
z & w
\end{pmatrix} \\
c \begin{pmatrix}
x & y \\
z & w
\end{pmatrix} & d \begin{pmatrix}
x & y \\
z & w
\end{pmatrix}
\end{pmatrix} =
\begin{pmatrix}
ax & ay & bx & by \\
az & aw & bz & bw \\
cx & cy & dx & dy \\
cz & cw & dz & dw
\end{pmatrix}
\end{align}

```python
A = np.matrix([[1j+8 , 2j-6], []])
```

    /usr/local/lib/python3.7/dist-packages/numpy/matrixlib/defmatrix.py:145: VisibleDeprecationWarning: Creating an ndarray from ragged nested sequences (which is a list-or-tuple of lists-or-tuples-or ndarrays with different lengths or shapes) is deprecated. If you meant to do this, you must specify 'dtype=object' when creating the ndarray
      arr = N.array(data, dtype=dtype, copy=copy)

```python
#!pip install qiskit
import qiskit
qiskit.__qiskit_version__
```

    {'qiskit-terra': '0.17.4', 'qiskit-aer': '0.8.2', 'qiskit-ignis': '0.6.0', 'qiskit-ibmq-provider': '0.13.1', 'qiskit-aqua': '0.9.1', 'qiskit': '0.26.1', 'qiskit-nature': None, 'qiskit-finance': None, 'qiskit-optimization': None, 'qiskit-machine-learning': None}

\begin{align}
|\psi \rangle = \begin{pmatrix}
\alpha \\ \beta
\end{pmatrix}, \quad \text{where } \sqrt{\langle \psi | \psi \rangle} = 1.
\end{align}

Think of Qubit as an Electron:

\begin{align}
\text{spin-up}: \ |0\rangle &= \begin{pmatrix} 1\\0 \end{pmatrix} \\
\text{spin-down}: \ |1\rangle & = \begin{pmatrix} 0\\1 \end{pmatrix}
\end{align}

```python
import numpy as np
from qiskit import *
from qiskit.visualization import plot_bloch_vector
%matplotlib inline
```

Another representation is via Bloch Sphere:

```python
plot_bloch_vector([0,0,1], title="Spin-Up")
```

## Spin + / - :

\begin{align}
\text{spin +}: \ |+\rangle &= \begin{pmatrix} 1/\sqrt{2} \\ 1/\sqrt{2} \end{pmatrix} = \frac{1}{\sqrt{2}} \left(|0\rangle + |1\rangle\right) \\
\text{spin -}: \ |-\rangle & = \begin{pmatrix} 1/\sqrt{2} \\ -1/\sqrt{2} \end{pmatrix} = \frac{1}{\sqrt{2}} \left(|0\rangle - |1\rangle\right)
\end{align}

```python
plot_bloch_vector([1,0,0], title="spin-plus")
```

## Basis States

\begin{align}
 |0\rangle &= \begin{pmatrix} 1\\0 \end{pmatrix} \\
 |1\rangle & = \begin{pmatrix} 0\\1 \end{pmatrix}
\end{align}
Preapring other states from Basis States:
\begin{align}
|00 \rangle &= |0\rangle \otimes |0\rangle = \begin{pmatrix} 1\\0 \end{pmatrix} \otimes \begin{pmatrix} 1\\0 \end{pmatrix} = \begin{pmatrix} 1\\0\\0\\0 \end{pmatrix} \\
|01 \rangle &= |0\rangle \otimes |1\rangle = \begin{pmatrix} 1\\0 \end{pmatrix} \otimes \begin{pmatrix} 0\\1 \end{pmatrix} = \begin{pmatrix} 0\\1\\0\\0 \end{pmatrix} \\
|10 \rangle &= |1\rangle \otimes |0\rangle = \begin{pmatrix} 0\\1 \end{pmatrix} \otimes \begin{pmatrix} 1\\0 \end{pmatrix} = \begin{pmatrix} 0\\0\\1\\0 \end{pmatrix} \\
|11 \rangle &= |1\rangle \otimes |1\rangle = \begin{pmatrix} 0\\1 \end{pmatrix} \otimes \begin{pmatrix} 0\\1 \end{pmatrix} = \begin{pmatrix} 0\\0\\0\\1 \end{pmatrix}
\end{align}

```python
qubit1_state = np.matrix([[1],[0]])
qubit2_state = np.matrix([[0],[1]])

two_qubit_state =np.kron(qubit1_state, qubit2_state)
```

# Task Three: Qunatum Gates and Circuits

```python
from qiskit import *
from qiskit.visualization import plot_bloch_multivector
```

## Pauli Matrices

\begin{align}
I = \begin{pmatrix} 1&0 \\ 0&1 \end{pmatrix}, \quad
X = \begin{pmatrix} 0&1 \\ 1&0 \end{pmatrix}, \quad
Y = \begin{pmatrix} 0&i \\ -i&0 \end{pmatrix}, \quad
Z = \begin{pmatrix} 1&0 \\ 0&-1 \end{pmatrix} \quad
\end{align}

## X-gate

The X-gate is represented by the Pauli-X matrix:

$$ X = \begin{bmatrix} 0 & 1 \\ 1 & 0 \end{bmatrix} = |0\rangle\langle1| + |1\rangle\langle0| $$

Effect a gate has on a qubit:

$$ X|0\rangle = \begin{bmatrix} 0 & 1 \\ 1 & 0 \end{bmatrix}\begin{bmatrix} 1 \\ 0 \end{bmatrix} = \begin{bmatrix} 0 \\ 1 \end{bmatrix} = |1\rangle$$

```python
# Let's do an X-gate on a |0> qubit
#!pip install pylatexenc
qc = QuantumCircuit(1)
qc.x(0)
qc.draw('mpl')
```

```python
# Let's see the result
backend = Aer.get_backend('statevector_simulator')
out = execute(qc,backend).result().get_statevector()
plot_bloch_multivector(out)
```

## Z & Y-Gate

$$ Y = \begin{bmatrix} 0 & -i \\ i & 0 \end{bmatrix} \quad\quad\quad\quad Z = \begin{bmatrix} 1 & 0 \\ 0 & -1 \end{bmatrix} $$

$$ Y = -i|0\rangle\langle1| + i|1\rangle\langle0| \quad\quad Z = |0\rangle\langle0| - |1\rangle\langle1| $$

```python
qc.y(0) # Do Y-gate on qubit 0
qc.z(0) # Do Z-gate on qubit 0
qc.draw('mpl')
```

## Hadamard Gate

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

We can see that this performs the transformations below:

$$ H|0\rangle = |+\rangle $$

$$ H|1\rangle = |-\rangle $$

```python
qc = QuantumCircuit(3)
# Apply H-gate to each qubit:
for qubit in range(3):
    qc.h(qubit)
# See the circuit:
qc.draw('mpl')
```

## Identity Gate

$$
I = \begin{bmatrix} 1 & 0 \\ 0 & 1\end{bmatrix}
$$

$$ I = XX $$

```python
qc = QuantumCircuit(1)
qc.i(0)
qc.draw('mpl')
```

 ** Other Gates: S-gate , T-gate, U-gate

## Multiple Qubits, Entanglement

## Multiple Qubits

The state of two qubits :

$$ |psi\rangle = a_{00}|00\rangle + a_{01}|01\rangle + a_{10}|10\rangle + a_{11}|11\rangle = \begin{bmatrix} a_{00} \\ a_{01} \\ a_{10} \\ a_{11} \end{bmatrix} $$

```python
from qiskit import *
```

```python
qc = QuantumCircuit(3)

for qubit in range(3):
    qc.h(qubit)

qc.draw('mpl')
```

Each qubit is in the state $|+\rangle$, so we should see the vector:

$$
|{+++}\rangle = \frac{1}{\sqrt{8}}\begin{bmatrix} 1 \\ 1 \\ 1 \\ 1 \\
                              1 \\ 1 \\ 1 \\ 1 \\
              \end{bmatrix}
$$

```python
backend = Aer.get_backend('statevector_simulator')
final_state= execute(qc, backend).result().get_statevector()
plot_bloch_multivector(out) #print(out)
```

```python
qc = QuantumCircuit(2)

qc.x(0)
qc.h(1)

qc.draw('mpl')
```

$$
X|q_1\rangle \otimes H|q_0\rangle = (X\otimes H)|q_1 q_0\rangle
$$

The operation looks like this:

$$
X\otimes H = \begin{bmatrix} 0 & 1 \\ 1 & 0 \end{bmatrix} \otimes \tfrac{1}{\sqrt{2}}\begin{bmatrix} 1 & 1 \\ 1 & -1 \end{bmatrix} = \frac{1}{\sqrt{2}}
\begin{bmatrix} 0 & 0 & 1 & 1 \\
                0 & 0 & 1 & -1 \\
                1 & 1 & 0 & 0 \\
                1 & -1 & 0 & 0 \\
\end{bmatrix}
$$

Which we can then apply to our 4D statevector $|q_1 q_0\rangle$. You will often see the clearer notation:

$$
X\otimes H =
\begin{bmatrix} 0 & H \\
               H & 0\\
\end{bmatrix}
$$

## C-Not Gate

```python
qc = QuantumCircuit(2)

qc.cx(0, 1)

qc.draw('mpl')

```

Classical truth table of C-Not gate:

| Input (t,c) | Output (t,c) |
|:-----------:|:------------:|
| 00          | 00           |
| 01          | 11           |
| 10          | 10           |
| 11          | 01           |

## Entanglement

```python
qc = QuantumCircuit(2)
# Apply H-gate to the first:
qc.h(0)
qc.draw('mpl')
```

```python
# Let's see the result:
backend = Aer.get_backend('statevector_simulator')
final_state = execute(qc,backend).result().get_statevector()
plot_bloch_multivector(final_state)
```

Quantum System Sate is:

$$
|0{+}\rangle = \tfrac{1}{\sqrt{2}}(|00\rangle + |01\rangle)
$$

```python
qc = QuantumCircuit(2)
# Apply H-gate to the first:
qc.h(0)
# Apply a CNOT:
qc.cx(0,1)
qc.draw('mpl')
```

```python
# Let's see the result:
backend = Aer.get_backend('statevector_simulator')
final_state = execute(qc,backend).result().get_statevector()
plot_bloch_multivector(final_state)
```

We see we have this final state (Bell State):

$$
\text{CNOT}|0{+}\rangle = \tfrac{1}{\sqrt{2}}(|00\rangle + |11\rangle)
$$

Other Bell States:

## Bernstein-Vazirani Algorithm

A black-box function $f$, which takes as input a string of bits ($x$), and returns either $0$ or $1$, that is:

$$f(\{x_0,x_1,x_2,...\}) \rightarrow 0 \textrm{ or } 1 \textrm{ where } x_n \textrm{ is }0 \textrm{ or } 1  $$

The function is guaranteed to return the bitwise product of the input with some string, $s$.

In other words, given an input $x$, $f(x) = s \cdot x \, \text{(mod 2)} =\ x_0 * s_0+x_1*s_1+x_2*s_2+...\ $ mod 2

# Classical-solution:

|Input(x)|
|:-----:|
|100...0|
|010...0|
|001...0|
|000...1|

Where each query reveals a different bit of $s$ (the bit $s_i$)

# The quantum Bernstein-Vazirani Oracle:

1. Initialise the inputs qubits to the $|0\rangle^{\otimes n}$ state, and output qubit to $|{-}\rangle$.
2. Apply Hadamard gates to the input register
3. Query the oracle
4. Apply Hadamard gates to the input register
5. Measure

## Example Two Qubits:

<ol>
    <li> The register of two qubits is initialized to zero:

$$\lvert \psi_0 \rangle = \lvert 0 0 \rangle$$

   </li>

   <li> Apply a Hadamard gate to both qubits:

$$\lvert \psi_1 \rangle = \frac{1}{2} \left( \lvert 0 0 \rangle + \lvert 0 1 \rangle + \lvert 1 0 \rangle + \lvert 1 1 \rangle \right) $$

   </li>

   <li> For the string $s=11$, the quantum oracle performs the operation:
$$
|x \rangle \xrightarrow{f_s} (-1)^{x\cdot 11} |x \rangle.
$$

$$\lvert \psi_2 \rangle = \frac{1}{2} \left( (-1)^{00\cdot 11}|00\rangle + (-1)^{01\cdot 11}|01\rangle + (-1)^{10\cdot 11}|10\rangle + (-1)^{11\cdot 11}|11\rangle \right)$$

$$\lvert \psi_2 \rangle = \frac{1}{2} \left( \lvert 0 0 \rangle - \lvert 0 1 \rangle - \lvert 1 0 \rangle + \lvert 1 1 \rangle \right)$$

   </li>

   <li> Apply a Hadamard gate to both qubits:

$$\lvert \psi_3 \rangle = \lvert 1 1 \rangle$$

   </li>

   <li> Measure to find the secret string $s=11$
   </li>

</ol>

```python
from qiskit import *
%matplotlib inline
from qiskit.tools.visualization import plot_histogram
```

```python
secret_num = '101001'
```

```python
circuit = QuantumCircuit(6+1,6)
circuit.h([0,1,2,3,4,5])

circuit.x(6)
circuit.h(6)

circuit.barrier()

circuit.cx(5,6)
circuit.cx(3,6)
circuit.cx(0,6)

circuit.barrier()
circuit.h([0,1,2,3,4,5])

circuit.measure([0,1,2,3,4,5],[0,1,2,3,4,5])
```

    <qiskit.circuit.instructionset.InstructionSet at 0x7f0a544bb8d0>

```python
circuit.draw(output= 'mpl')
```

```python
simulator = Aer.get_backend('qasm_simulator')
result = execute(circuit, backend= simulator, shots = 1).result()
counts= result.get_counts()
print(counts)
```

    {'101001': 1}

Programming a Quantum Computer with Qiskit/Programming a Quantum Computer with Qiskit.ipynb


# Goal
- implement the gradient-based learning using sampling
- implement the EM method
- compare the two using synthetic data

# Introduction
Our goal in this exercise is to learn the parameters of a model from incomplete data. The model is a mixture of Guassians. According to this model, each data point is sampled from one of $C$ Gaussian distributions, according to the class of that data point. The prior probability for class label follows a multinomial distribution:

\begin{equation}
P(Z_i = j) = p_j
\end{equation}

Given the class of a data point, its value follows a normal distribution with parameters that are specific to that class:

\begin{equation}
P(Y_i = y | Z_i = j) = N(y; \mu_j, \sigma_j)
\end{equation}

## Sampling the latent variables
For each data point $Y_i$ we have:

\begin{align*}
P(Z_i=j|Y_i=y_i;\Theta) & \propto P(Y_i=y_i|Z_i=j;\Theta)P(Z_i=j;\Theta)   \\
                  & = N(y_i;\mu_j,\sigma_j) \cdot p_j
\end{align*}

Having the posterior of class label, we can sample the label from this posterior:

```python
from scipy.stats import norm
from numpy.random import multinomial

# returns a vector with counts of each class
def sample_label(parameters, y, num_samples=1):
    multinomial_parameters, normal_parameters = parameters
    assert len(multinomial_parameters) == len(normal_parameters)
    num_classes = len(multinomial_parameters)

    posterior = [0.0] * num_classes
    for i in range(num_classes):
        mu, sigma = normal_parameters[i]
        p = multinomial_parameters[i]
        posterior[i] = norm.pdf(y, loc=mu, scale=sigma) * p
    s = sum(posterior)
    posterior = [ps/s for ps in posterior]
    return multinomial(num_samples, posterior)

```

Let us test the correctness of gradients of normal distribution. Given $f(x, \mu, \sigma) = \frac{1}{\sigma\sqrt{2\pi}} e^{\frac{(x-\mu)^2}{2\sigma^2}}$, I believe that:
\begin{align}
\frac{\partial f}{\partial \mu} &= f . \frac{1}{\sigma} . \big(\frac{x-\mu}{\sigma}\big) \\
\frac{\partial f}{\partial \sigma} &= f . \frac{1}{\sigma} . \big( (\frac{x-\mu}{\sigma})^2 -1 \big)
\end{align}

```python
from scipy.optimize import check_grad
from scipy.stats import norm
from numpy.random import random_sample, normal

def f_normal(x):
    def _f(inp):
        mu, sigma = inp
        return norm.pdf(x, loc=mu, scale=sigma)
    return _f

def fprime_normal(x):
    def _fprime(inp):
        f_val = f(x)(inp)
        mu, sigma = inp
        xms = (x-mu)/sigma

        grad=[0.0]*2
        grad[0] = f_val/sigma * xms
        grad[1] = f_val/sigma * (xms**2 - 1)
        return grad
    return _fprime

# for i in range(10000):
#     mu = 30 * random_sample()
#     sigma = 10 * random_sample()
#     val = normal(mu, sigma)
#     diff =  check_grad(f(val), fprime(val), [mu, sigma])
#     if diff > 1e-4:
#         print mu, sigma, val, diff
```

```python
from numpy.random import random_sample, normal
from scipy.stats import norm

def f_normal_log(x):
    return lambda y : norm.logpdf(x, loc=y[0], scale=y[1])

def fprime_normal_log(x):
    def fprime_log_(y):
        mu, sigma = y
        xms = (x-mu)/sigma
        fprime_mu = xms / sigma
        fprime_sigma = (xms**2 - 1) / sigma
        return (fprime_mu, fprime_sigma)
    return fprime_log_

for i in range(5000):
    mu = 30 * random_sample()
    sigma = 10 * random_sample()
    val = normal(mu, sigma)
    diff =  check_grad(f_normal_log(val), fprime_normal_log(val), [mu, sigma])
    if diff > 1e-4:
        print mu, sigma, val, diff
```

    28.2928469665 0.00592172043732 28.2863311113 0.000598214503615
    8.96909573673 0.000621093559227 8.96901323197 0.0266009950405
    28.1549881946 0.0068173659069 28.1529651645 0.000199039264796
    9.80309539067 0.00749007908259 9.81579395677 0.00102107474286
    19.9941297072 0.00911564481845 19.9917446729 0.00011452526983
    25.5092916176 0.0221553537523 25.4729479686 0.000108410052508
    27.9116999558 0.00186872685539 27.9092031722 0.00953395716171
    10.0643932165 0.00784502673061 10.0624190567 0.000155770172607
    29.1572354162 0.00705287246878 29.1434574364 0.00157213412558
    2.32903647632 0.00168510148118 2.32796070813 0.0026881099904
    7.45309686299 0.01909445103 7.41427392373 0.000233930416804

```python
def test_multinomial(f_multinomial_pos, fprime_multinomial_pos,
                    num_tests = 1000):
    num_classes = np.random.randint(40)
    for i in range(num_tests):
        params = [normal(loc=0, scale=1) for i in range(num_classes)]
        p2 = np.exp(params)
        p2 = [i/sum(p2) for i in p2]
        val = multinomial(1, p2)
        val = numpy.where(val==1)[0][0]
        diff =  check_grad(f_multinomial_pos(val), fprime_multinomial_pos(val), params)
        if diff > 1e-4:
            print params, val, diff
            print f_multinomial_pos(val)(params)
            print fprime_multinomial_pos(val)(params)
            print approx_fprime(params, f_multinomial_pos(val), 1.49e-08)
            print
    print 'test finished'
```

```python
def f_multinomial_pos(label):
    return (lambda parameters : np.exp(parameters[label]) / sum(np.exp(parameters)))

def fprime_multinomial_pos(label):
    def _fprime_multinomial_pos(parameters):
        f = [f_multinomial_pos(i)(parameters) for i in range(len(parameters))]
        f_i = f[label]
        fprime = [-f_i*f_j for f_j in f]
        fprime[label] = f_i*(1-f_i)
        return fprime
    return _fprime_multinomial_pos

test_multinomial(f_multinomial_pos, fprime_multinomial_pos)
```

    test finished

```python
def f_multinomial_log(label):
    return (lambda parameters : parameters[label] - np.log(sum(np.exp(parameters))))

def fprime_multinomial_log(label):
    def _fprime_multinomial_log(parameters):
        fprime = [-f_multinomial_pos(i)(parameters) for i in range(len(parameters))]
        fprime[label] += 1
        return fprime
    return _fprime_multinomial_log

test_multinomial(f_multinomial_log, fprime_multinomial_log)
```

## Approximating the gradient
The log likelihood of data point $i$ can be written as $L(\Theta) = \log P(Y_i, Z_i;\Theta)$.
So the gradient of log-likelihood function can be written as:

\begin{equation}
\partial_{\Theta} L(\Theta) =
\end{equation}

```python
def ll(parameters, data):
    """Compute the likelihood and its gradients.

    arguments:
    parameters -- current value of parameters
    data     -- values for all random variables
    """
    p_n, p_m = parameters
    likelihood = 0
    grad_n = [0.0] * len(p_n[0])
    grad_m = [0.0] * len(p_m)
    for instance in data:
        label, value = instance
        likelihood += f_normal_log(value)(p_n[label])
        grad_n += fprime_normal_log(value)(p_n[label])
        likelihood += f_multinomial_log(label)(p_m)
        grad_m += fprime_multinomial_log(label)(p_m)

    gradients = grad_n, grad_m
    return likelihood, gradients

```

## A very important observation
The differences in the values of `multinomial_parameters` leads to exponential differences, and does not let samples from other classes to be generated.

```python
import numpy as np

def generate_data():
    num_classes = 2
    normal_parameters = [
        [10, 5],
        [25, 3]
    ]
    multinomial_parameters = [15.5, 16]
    p2 = np.exp(multinomial_parameters)
    p2 = [i/sum(p2) for i in p2]

    num_data = 1000
    values = []
    labels = []
    for i in range(num_data):
        label = np.random.multinomial(1, p2)
        label = np.where(label==1)[0][0]
        mu, sigma = normal_parameters[label]
        value = np.random.normal(mu, sigma)
        values.append(value)
        labels.append(label)
    return values, labels

values, labels = generate_data()
data = zip(labels, values)
```

```python
import matplotlib.pyplot as plt
from scipy.stats import norm
plt.hist(values,100)
x = np.linspace(-20, 50, num=100)
y1 = norm.pdf(x, loc=10, scale=5) * 200
y3 = norm.pdf(x, loc=25, scale=3) * 300
plt.plot(x, y1)
plt.plot(x, y3)
```

Assuming that the class labels are hidden, we only have access to `values`. In each iteration, we complete the data using current parameters, compute the likelihood and gradient of completed data, and update the parameters:

```python
# returns a vector with counts of each class
def complete_data(parameters, value, num_samples=1):
    normal_parameters, multinomial_parameters = parameters
    assert len(multinomial_parameters) == len(normal_parameters)
    num_classes = len(multinomial_parameters)
    p = np.exp(multinomial_parameters)
    p = [pj/sum(p) for pj in p]

    posterior = [0.0] * num_classes
    for i in range(num_classes):
        mu, sigma = normal_parameters[i]
        posterior[i] = norm.pdf(value, loc=mu, scale=sigma) * p[i]
    s = sum(posterior)
    posterior = [ps/s for ps in posterior]
    labels =  np.random.multinomial(num_samples, posterior)
    return [(label, value) for label in labels]

def iterate(parameters):
    completed_data = []
    for value in values:
        completed_data.append(complete_data(parameters, value))
    likelihood, gradients = ll(parameters, completed_data)
    parameters = parameters - alpha * gradients
    return parameters

normal_parameters = [
    [10, 5],
    [25, 3]
]

multinomial_parameters = [15.5, 16]
parameters = normal_parameters, multinomial_parameters
print parameters
print iterate(parameters)
```


## Exercise set 5: causal forest

In this exercise set we will be working with the `econml` package to estimate a causal forest.

Another more general implementation is found in [generalized random forest](https://github.com/grf-labs/grf) by Athey et al. The package is written for the R programming language.

```python
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns

from sklearn.datasets import make_classification

sns.set(style='darkgrid')

%matplotlib inline
```

To highlight the usefulness of causal forest we will be working with synthetic data in this exercise. In particular we will synthetically add a treatment effect to a dataset in which there otherwise is none. Furthermore we will make this effect heterogeneous by adding noise, and by making it depend on a single continuous variable as well as a categorical variable.

>**Ex. 5.1.0:** Use the code below to simulate data according to

\begin{align}
T(X) &= \frac{1}{1+e^{-X\delta+U}} > 0.5 \\
\tau(X) &=  \frac{1}{1+e^{-\gamma X_0}} \\
Y(T=0) &= X\beta + \epsilon \\
Y(T=1) &= Y(0) + \tau(X) \\
\end{align}

where $\epsilon, \nu$ are simply noise terms distributed according to $\mathcal{N}(0,1)$ and $\beta,\delta$ are `N_FEATURES` vector of random parameters. $\gamma$ is a scalar parameter.

```python
N_SAMPLES = 10000
N_FEATURES = 5
GAMMA = 1.2
BETA = np.random.RandomState(0).uniform(0,1, size = N_FEATURES)
DELTA = np.random.RandomState(1).uniform(0,1, size = N_FEATURES)

X = np.random.RandomState(2).normal(size = (N_SAMPLES, N_FEATURES))

U = np.random.RandomState(3).normal(size = (N_SAMPLES))
T = 1/(1+np.exp(-(U+X.dot(DELTA))))>.5

Y0 = X @ BETA + np.random.RandomState(5).normal(size = N_SAMPLES)
tau = 10/(1 + np.exp(-GAMMA*X[:,0])) + np.random.normal(size = N_SAMPLES)
Y1 = Y0 + tau
y = Y0 + T*(Y1 - Y0)
```

> **Ex. 5.1.1:** Create a two-subplot figure, and plot $Y(0)$ and $Y(1)$ in one subplot against $X_0$. Plot $\tau(x)$ against $X_0$ in the other subplot. What do you see? Why do we observe $\tau=0$ in many cases?

```python
# Your answer here
```

> **Ex. 5.1.2:** Is there a selection problem? Plot for each dimension of $X$ the relationship with treatment assignment.

```python
# Your answer here
```

>**Ex.5.1.3:** Estimate a causal forest model using the `econml` package, and store the model in a new variable `cf`.
>
> To unconfound the treatment assignment, use the gradient boosted forest in the first "double machine learning" step. Then use the following line to create a dataframe of predicted treatment effects on the same data that you trained the model on.
>> Hint: use the following setting
>>```python
discrete_treatment=True
```

```python
# Your answer here
```

>**Ex.5.1.4:** Plot a scatterplot of the estimated individual treatment effects against the simulated "true" ITE's `tau` that you produced in the beginning of this exercise set.

```python
# Your answer here
```


# 1 - Importing cmpx

```python
import cmpx
```

```python
cmpx.__version__
```

    '0.8.4'

# 2 - Instanciate a Complex class

```python
number = cmpx.Complex(re=1, im=3.2, restore=True)
```

```python
number
```

    1 + 3.2j

```python
type(number)
```

    cmpx.number.Complex

### As you can see, whenever you create a new object you have to give:
- re: real part of complex number.
- im: imaginary part of complex number.
- restore: this argument is used whenever you want to restore the last number you had to avoid assigning None to your object, sometimes operations are not allowed like dividing by zero so Python interpreter assign None whenever this happened.
### Default values of the constructor are as follows:
- re = 0
- im = 0
- restore = True

```python
number = cmpx.Complex()
number
```

    0

## There's another way to create a complex number

```python
number = cmpx.Complex.fromComplex(1 - 1j)
number
```

    1.0 - j

- As you can see, you can put directly the complex number as you know of, but keep in mind that the imaginary part must be always multiplied by a coefficient as above.

```python
number = cmpx.Complex.fromComplex(2 - 2j)
number
```

    2.0 - 2.0j

## To avoid the long wrtinig you can import directly the Complex class

```python
from cmpx import Complex

number = Complex(1,3)
number
```

    1 + 3j

# 3 - Mathematical operations
- There's a lot of operations you can do!

```python
num1 = cmpx.Complex.fromComplex(1 - 2j)
num2 = cmpx.Complex.fromComplex(-2 + 1j)
```

## Addition

```python
result = num1 + num2
result
```

    -1.0 - j

## Substraction

```python
result = num1 - num2
result
```

    3.0 - 3.0j

## Multiplication

```python
result = num1 * num2
result
```

    5.0j

## Division

```python
result = num1 / num2
result
```

    -0.8 + 0.6j

```python
result = num1 // num2
result
```

    -1.0

- You can also affect directly to the number by unary operators (+=, -=, *=, /=, //=).
- You can also make operations with non instanciated complex numbers as follows.

```python
num1 += 1
num1
```

    2.0 - 2.0j

```python
num1 *= 1 - 3j
num1
```

    -4.0 + 10.0j

```python
num1 /= 0
num1
```

    Float division by zero
    Restoring last number

    -4.0 + 10.0j

- Oh oh! you saw what happened there? as we spoke about it above, the restore field is playing a major factor here, because by default restore is True and because of that it will restore the last number you had.
- Also, you can make restore=False if you don't want to recover the last number.

## Module

```python
num2.mod()
```

    2.23606797749979

## Conjugate

```python
num2.con()
```

    -2.0 - j

# 4 - Comparisons
- You can also do comparison between complex numbers based on their modules.

```python
num1 = cmpx.Complex(1, 2)
num2 = cmpx.Complex(-2, -3)
```

```python
if num1 > num2:
    print('({}) > ({})'.format(num1, num2))
```

```python
if num1 >= num2:
    print('({}) >= ({})'.format(num1, num2))
```

```python
if num1 < num2:
    print('({}) < ({})'.format(num1, num2))
```

    (1 + 2j) < (-2 - 3j)

```python
if num1 <= num2:
    print('({}) <= ({})'.format(num1, num2))
```

    (1 + 2j) <= (-2 - 3j)

```python
if num1 == num2:
    print('({}) == ({})'.format(num1, num2))
```

```python
if num1 != num2:
    print('({}) != ({})'.format(num1, num2))
```

    (1 + 2j) != (-2 - 3j)

- You can also do comparisons with non instanciated numbers as follows.

```python
if num1 > (1 - 0.5j):
    print('({}) > ({})'.format(num1, (1-0.5j)))
```

    (1 + 2j) > ((1-0.5j))

# 5 - Solving linear and second degree equations
- You can solve first or second degree equation using solve function, it takes three arguments and those are the coefficients (a, b and c) :
\begin{equation}
ax^2 + bx + c = 0
\end{equation}
- solve function returns a tuple of solutions which they are Complex objects.

```python
from cmpx.equations import solve
```

```python
solutions = solve(-1, 2.5, -4)
```

```python
solutions
```

    (1.25 + 1.5612494995995996j, 1.25 - 1.5612494995995996j)

```python
type(solutions[0])
```

    cmpx.number.Complex


```python
from __future__ import print_function
```

# Lecture 10
# SymPy - the Python Computer Algebra System
# pydoe - DOE in Python

```python
from time import time
import numpy as np
```

```python
x = np.random.random(10000000)

```

```python
t0 = time()
y = [2.0*i for i in x]; y = np.array(y)
print(time()-t0)
```

    2.07593607903

```python
t1 = time()
y = 2.0*x
print(time()-t1)
```

    0.0170731544495

```python
my_string = 'jadfad sdfasdf assdfa 1.0 adfa asdfasd\n'
my_string = my_string.split(' ')
my_number = float(my_string[3])
print(my_number)
```

    1.0

```python
from sympy import *
```

```python
w, x, y, z = symbols('w x y z')
```

```python
print(w)
```

    w

```python
a = 3.0*x**2 + 1.0*x
```

```python
a
```

    3.0*x**2 + 1.0*x

```python
1.0/3.0
```

    0.3333333333333333

```python
Rational(1,3)
```

    1/3

```python
a
```

    3.0*x**2 + 1.0*x

```python
a.subs(x,y**2)
```

    3.0*y**4 + 1.0*y**2

```python
a
```

    3.0*x**2 + 1.0*x

```python
Rational?
```

```python
b = a.subs(x,y**2)
```

```python
b
```

    3.0*y**4 + 1.0*y**2

```python
a
```

    3.0*x**2 + 1.0*x

```python
a = a.subs(x,y**2)
```

```python
a
```

    3.0*y**4 + 1.0*y**2

```python
init_printing()
```

```python
a
```

```python
latex(a)
```

    '3.0 y^{4} + 1.0 y^{2}'

```python
init_printing(use_latex=False, use_unicode=False)
```

```python
a
```

         4        2
    3.0*y  + 1.0*y

```python
print(a)
```

    3.0*y**4 + 1.0*y**2

```python
pprint(a)
```

         4        2
    3.0*y  + 1.0*y

```python
pprint(a.integrate(x))
```

      /     4        2\
    x*\3.0*y  + 1.0*y /

```python

```


```python
import sympy

# Juypter 노트북에서 수학식의 LaTeX 표현을 위해 필요함
sympy.init_printing(use_latex='mathjax')
```

```python
# sympy를 사용하면 아무런 숫자도 대입되지 않는 변수 생성 가능 (수식!)
# log같은 수학 연산을 취할때 np에 있는걸 사용하면 안되고 sympy에 있는걸 사용해야함
```

```python
x = sympy.symbols('x')
x
```

$\displaystyle x$

```python
type(x)
```

    sympy.core.symbol.Symbol

```python
f = x * sympy.exp(x)
f
```

$\displaystyle x e^{x}$

```python
# 미분
sympy.diff(f)
```

$\displaystyle x e^{x} + e^{x}$

```python
sympy.diff(f)
```

$\displaystyle x e^{x} + e^{x}$

```python
sympy.simplify(sympy.diff(f))
```

$\displaystyle \left(x + 1\right) e^{x}$

```python
x, y = sympy.symbols('x y')
f = x ** 2 + 4 * x * y + 4 * y ** 2
f
```

$\displaystyle x^{2} + 4 x y + 4 y^{2}$

```python
sympy.diff(f, x)
```

$\displaystyle 2 x + 4 y$

```python
sympy.diff(f, y)
```

$\displaystyle 4 x + 8 y$

```python
x, mu, sigma = sympy.symbols('x mu sigma')
f = sympy.exp((x - mu) ** 2 / sigma ** 2)
f
```

$\displaystyle e^{\frac{\left(- \mu + x\right)^{2}}{\sigma^{2}}}$

```python
# x로 미분하는걸 명시해줘야함
sympy.diff(f, x)
```

$\displaystyle \frac{\left(- 2 \mu + 2 x\right) e^{\frac{\left(- \mu + x\right)^{2}}{\sigma^{2}}}}{\sigma^{2}}$

```python
# 이차도함수 -> X에 대해 두번 미분
sympy.diff(f, x, x)
```

$\displaystyle \frac{2 \left(1 + \frac{2 \left(\mu - x\right)^{2}}{\sigma^{2}}\right) e^{\frac{\left(\mu - x\right)^{2}}{\sigma^{2}}}}{\sigma^{2}}$

#### 연습문제 4.2.5

```python
x= sympy.symbols('x')
f= x**3-1
```

```python
sympy.diff(f,x)
```

$\displaystyle 3 x^{2}$

```python
x,k=sympy.symbols('x k')
f=sympy.log(x**2-3*k)
sympy.diff(f,x)
```

$\displaystyle \frac{2 x}{- 3 k + x^{2}}$

```python
x,a,b=sympy.symbols('x a b')
f=sympy.exp(a*x**b)
sympy.diff(f,x),sympy.simplify(sympy.diff(f,x))
```

$\displaystyle \left( \frac{a b x^{b} e^{a x^{b}}}{x}, \  a b x^{b - 1} e^{a x^{b}}\right)$

#### 연습문제 4.2.6

```python
x,y=sympy.symbols('x y')
f=sympy.exp(x**2+2*y**2)
```

```python
sympy.diff(f,x)
```

$\displaystyle 2 x e^{x^{2} + 2 y^{2}}$

```python
sympy.diff(f,y)
```

$\displaystyle 4 y e^{x^{2} + 2 y^{2}}$

```python
sympy.diff(f,x,x)
```

$\displaystyle 2 \left(2 x^{2} + 1\right) e^{x^{2} + 2 y^{2}}$

```python
sympy.diff(f,x,y)
```

$\displaystyle 8 x y e^{x^{2} + 2 y^{2}}$

```python
sympy.diff(f,y,x)
```

$\displaystyle 8 x y e^{x^{2} + 2 y^{2}}$

```python
sympy.diff(f,y,y)
```

$\displaystyle 4 \left(4 y^{2} + 1\right) e^{x^{2} + 2 y^{2}}$

### 심파이를 이용한 부정적분

```python
sympy.init_printing(use_latex='mathjax')

x = sympy.symbols('x')
f = x * sympy.exp(x) + sympy.exp(x)
f
```

$\displaystyle x e^{x} + e^{x}$

```python
sympy.integrate(f)
```

$\displaystyle x e^{x}$

```python
x, y = sympy.symbols('x y')
f = 2 * x + y
f
```

$\displaystyle 2 x + y$

```python
sympy.integrate(f, x)
```

$\displaystyle x^{2} + x y$

#### 연습문제 4.3.4

```python
x=sympy.symbols('x')
f=3*x**2
sympy.integrate(f,x)
```

$\displaystyle x^{3}$

```python
x=sympy.symbols('x')
f=3*x**2-6*x+1
sympy.integrate(f,x)
```

$\displaystyle x^{3} - 3 x^{2} + x$

```python
x=sympy.symbols('x')
f=2+6*x+4*sympy.exp(x)+5/x
sympy.integrate(f,x)
```

$\displaystyle 3 x^{2} + 2 x + 4 e^{x} + 5 \log{\left(x \right)}$

```python
x=sympy.symbols('x')
f=2*x/(x**2-1)
sympy.integrate(f,x)
```

$\displaystyle \log{\left(x^{2} - 1 \right)}$

### 정적분

```python
x, y = sympy.symbols('x y')
f = x ** 3 - 3 * x ** 2 + x + 6
f
```

$\displaystyle x^{3} - 3 x^{2} + x + 6$

```python
# 부정 적분
F=sympy.integrate(f)
F
```

$\displaystyle \frac{x^{4}}{4} - x^{3} + \frac{x^{2}}{2} + 6 x$

```python
# subs : 대입
# evalf : 수치 표현식을 부동소수점 숫자로 평가
F.subs(x,2)-F.subs(x,0).evalf()
```

$\displaystyle 10$

### 수치적분

```python
import scipy as sp
from scipy import integrate
```

```python
def f(x):
    return x ** 3 - 3 * x ** 2 + x + 6

scipy.integrate.quad(f, 0, 2)
# 정적분 (수치적분)
# 적분값, 오차 형태로 결과 나옴
```

    (10.0, 1.1102230246251565e-13)

### scipy를 이용한 최적화

```python
def f1(x):
    return (x-2)**2+2
```

```python
x0=0 # 초깃값
result=  sp.optimize.minimize(f1,x0)
print(result)
#success :T -> 최저값 찾는데 성공 -> 그값이 x
# fuc x의 위치에서 함수값
# hess_inv: 헤시안 값 -> 양수면 아래로 볼록이라 최저점이 맞음
# jac: 자코비안 행렬값 =0이면 최저값 맞음
# nit: 두번의 이동만에 끝냄 x0(초깃값)->x1->x2
# njev: 기울기 계산은 세번 -> 초깃값이 최저일수도 있으니 그때도 한번 계산
# nfev: 앞뒤 함수값 구해서 그 두값의 기울기를 구함
# 앞의값 뒤의값 자기자신의 값 이렇게 한포인트마다 세번씩 계산함
# 그래서 nfev는 (초깃값 포함한 이동한 점개수)*3
```

          fun: 2.0
     hess_inv: array([[0.5]])
          jac: array([0.])
      message: 'Optimization terminated successfully.'
         nfev: 9
          nit: 2
         njev: 3
       status: 0
      success: True
            x: array([1.99999999])

```python
def f1p(x):
#     f1의 도함수
    return 2*(x-2)
```

```python
result=  sp.optimize.minimize(f1,x0,jac=f1p)
result
# nfev 가 9 ->3 으로 변화
# 기울기를 구할때 한포인트당 세번씩 계산을 했어야함
# 하지만 자코비안을넣어주면(도함수) 대입으로 기울기를 구할 수 있음
# 따라서 포인트당 한번씩의 계산만 함
```

          fun: 2.0
     hess_inv: array([[0.5]])
          jac: array([0.])
      message: 'Optimization terminated successfully.'
         nfev: 3
          nit: 2
         njev: 3
       status: 0
      success: True
            x: array([2.])

### 다변수 함수의 최적화(목적함수가 벡터를 인수로)

```python
# 2차원 목적함수
def f2(x):
    return (1-x[0])**2 + 100.0*(x[1]-x[0]**2)**2

x0=(-2,-2)
result= sp.optimize.minimize(f2,x0)
print(result)
# 초깃값 아무렇게나 줌
# 최저값 찾는데 성공 못함
# 횟수 이상으로는 안돌아감 진동(oscillation) 현상이 있는 함수
```

          fun: 5.2112890141803265e-12
     hess_inv: array([[0.49938636, 0.99860874],
           [0.99860874, 2.00187364]])
          jac: array([ 7.32138760e-05, -3.35902622e-05])
      message: 'Desired error not necessarily achieved due to precision loss.'
         nfev: 536
          nit: 55
         njev: 131
       status: 2
      success: False
            x: array([0.99999854, 0.9999969 ])

#### 연습 문제 5.1.1
- 1) 최적해에 수렴할 수 있도록 초기점을 변경하여 본다.

```python
x0=(0,0)
result=sp.optimize.minimize(f2,x0)
print(result)
```

          fun: 2.8440316101421118e-11
     hess_inv: array([[0.49482454, 0.98957634],
           [0.98957634, 1.98394215]])
          jac: array([ 3.98673709e-06, -2.84416559e-06])
      message: 'Optimization terminated successfully.'
         nfev: 96
          nit: 19
         njev: 24
       status: 0
      success: True
            x: array([0.99999467, 0.99998932])

- 2)그레디언트 벡터 함수를 구현하여 jac인수로 주는 방법으로 계산 속도를 향상시킨다

```python
def f2p(x):
    return np.array((2.0*(x[0]-1)- 400.0*x[0]*(x[1]-x[0]**2),
                    200.0*(x[1]-x[0]**2)))
# 들어가는것도 벡터 나오는것도 벡터 때문에 ( , )꼴
```

```python
x0=(0,0)
result = sp.optimize.minimize(f2,x0,jac=f2p)
print(result)
```

          fun: 7.717288359756836e-13
     hess_inv: array([[0.49480256, 0.98953879],
           [0.98953879, 1.98387918]])
          jac: array([ 3.92841205e-06, -2.83120876e-06])
      message: 'Optimization terminated successfully.'
         nfev: 24
          nit: 19
         njev: 24
       status: 0
      success: True
            x: array([0.99999913, 0.99999825])

```python

```


# Derivatives fundamentals

This notebook will introduce you to the fundamentals of computing the derivative of the solution map to optimization problems. The derivative can be used for **sensitvity analysis**, to see how a solution would change given small changes to the parameters, and to compute **gradients** of scalar-valued functions of the solution.

In this notebook, we will consider a simple disciplined geometric program. The geometric program under consideration is

$$
\begin{equation}
\begin{array}{ll}
\mbox{minimize} & 1/(xyz) \\
\mbox{subject to} & a(xy + xz + yz) \leq b\\
& x \geq y^c,
\end{array}
\end{equation}
$$

where $x \in \mathbf{R}_{++}$, $y \in \mathbf{R}_{++}$, and $z \in \mathbf{R}_{++}$ are the variables, and $a \in \mathbf{R}_{++}$, $b \in \mathbf{R}_{++}$ and $c \in \mathbf{R}$ are the parameters. The vector
$$
\alpha = \begin{bmatrix} a \\ b \\ c \end{bmatrix}
$$
is the vector of parameters.

```python
import cvxpy as cp

x = cp.Variable(pos=True)
y = cp.Variable(pos=True)
z = cp.Variable(pos=True)

a = cp.Parameter(pos=True)
b = cp.Parameter(pos=True)
c = cp.Parameter()

objective_fn = 1/(x*y*z)
objective = cp.Minimize(objective_fn)
constraints = [a*(x*y + x*z + y*z) <= b, x >= y**c]
problem = cp.Problem(objective, constraints)

problem.is_dgp(dpp=True)
```

    True

Notice the keyword argument `dpp=True`. The parameters must enter in the DGP problem acording to special rules, which we refer to as `dpp`. The DPP rules are described in an [online tutorial](https://www.cvxpy.org/tutorial/advanced/index.html#disciplined-parametrized-programming).

Next, we solve the problem, setting the parameters $a$, $b$ and $c$ to $2$, $1$, and $0.5$.

```python
a.value = 2.0
b.value = 1.0
c.value = 0.5
problem.solve(gp=True, requires_grad=True)

print(x.value)
print(y.value)
print(z.value)
```

    0.5612147353889386
    0.31496200373359456
    0.36892055859991446

Notice the keyword argument ``requires_grad=True``; this is necessary to subsequently compute derivatives.

## Solution map

The **solution map** of the above problem is a function
$$\mathcal{S} : \mathbf{R}^2_{++} \times \mathbf{R} \to \mathbf{R}^3_{++}$$
which maps the parameter vector to the vector of optimal solutions
$$
\mathcal S(\alpha) = \begin{bmatrix} x(\alpha) \\ y(\alpha) \\ z(\alpha)\end{bmatrix}.
$$

Here, $x(\alpha)$, $y(\alpha)$, and $z(\alpha)$ are the optimal values of the variables corresponding to the parameter vector.

As an example, we just saw that
$$
\mathcal S((2.0, 1.0, 0.5)) = \begin{bmatrix} 0.5612 \\ 0.3150 \\ 0.3690 \end{bmatrix}.
$$

## Sensitivity analysis

When the solution map is differentiable, we can use its derivative
$$
\mathsf{D}\mathcal{S}(\alpha) \in \mathbf{R}^{3 \times 3}
$$
to perform a **sensitivity analysis**, which studies how the solution would change given small changes to the parameters.

Suppose we perturb the parameters by a vector of small magnitude $\mathsf{d}\alpha \in \mathbf{R}^3$. We can approximate the change $\Delta$ in the solution due to the perturbation using the derivative, as
$$
\Delta = \mathcal{S}(\alpha + \mathsf{d}\alpha) - \mathcal{S}(\alpha) \approx \mathsf{D}\mathcal{S}(\alpha) \mathsf{d}\alpha.
$$

We can compute this in CVXPY, as follows.

Partition the perturbation as
$$
\mathsf{d}\alpha = \begin{bmatrix} \mathsf{d}a \\ \mathsf{d}b \\ \mathsf{d}c\end{bmatrix}.
$$

We set the ``delta`` attributes of the parameters to their perturbations, and then call the ``derivative`` method.

```python
da, db, dc = 1e-2, 1e-2, 1e-2

a.delta = da
b.delta = db
c.delta = dc
problem.derivative()
```

The ``derivative`` method populates the ``delta`` attributes of the variables as a side-effect, with the predicted change in the variable. We can compare the predictions to the actual solution of the perturbed problem.

```python
x_hat = x.value + x.delta
y_hat = y.value + y.delta
z_hat = z.value + z.delta

a.value += da
b.value += db
c.value += dc

problem.solve(gp=True)
print('x: predicted {0:.5f} actual {1:.5f}'.format(x_hat, x.value))
print('y: predicted {0:.5f} actual {1:.5f}'.format(y_hat, y.value))
print('z: predicted {0:.5f} actual {1:.5f}'.format(z_hat, z.value))

a.value -= da
b.value -= db
c.value -= dc
```

    x: predicted 0.55729 actual 0.55734
    y: predicted 0.31783 actual 0.31783
    z: predicted 0.37179 actual 0.37175

In this case, the predictions and the actual solutions are fairly close.

## Gradient

We can compute gradient of a scalar-valued function of the solution with respect to the parameters. Let $f : \mathbf{R}^{3} \to \mathbf{R}$, and suppose we want to compute the gradient of the composition $f \circ \mathcal S$. By the chain rule,
$$
\nabla f(S(\alpha)) = \mathsf{D}^T\mathcal{S}(\alpha) \begin{bmatrix}\mathsf{d}x \\ \mathsf{d}y \\ \mathsf{d}z\end{bmatrix},
$$
where $\mathsf{D}^T\mathcal{S}$ is the adjoint (or transpose) of the derivative operator, and $\mathsf{d}x$, $\mathsf{d}y$, and $\mathsf{d}z$ are the partial derivatives of $f$ with respect to its arguments.

We can compute the gradient in CVXPY, using the ``backward`` method. As an example, suppose
$$
f(x, y, z) = \frac{1}{2}(x^2 + y^2 + z^2),
$$
so that $\mathsf{d}x = x$, $\mathsf{d}y = y$, and $\mathsf{d}z = z$. Let $\mathsf{d}\alpha = \nabla f(S(\alpha))$,
and suppose we subtract $\eta \mathsf{d}\alpha$ from the parameter, where $\eta$ is a positive constant. Using the following code, we can compare $f(\mathcal S(\alpha - \eta \mathsf{d}\alpha))$ with the value predicted by the gradient,
$$
f(\mathcal S(\alpha - \eta \mathsf{d}\alpha)) \approx f(\mathcal S(\alpha)) - \eta \mathsf{d}\alpha^T\mathsf{d}\alpha.
$$

```python
problem.solve(gp=True, requires_grad=True)

def f(x, y, z):
    return 1/2*(x**2 + y**2 + z**2)

original = f(x, y, z).value

x.gradient = x.value
y.gradient = y.value
z.gradient = z.value
problem.backward()

eta = 0.5
dalpha = cp.vstack([a.gradient, b.gradient, c.gradient])
predicted = float((original - eta*dalpha.T @ dalpha).value)

a.value -= eta*a.gradient
b.value -= eta*b.gradient
c.value -= eta*c.gradient
problem.solve(gp=True)
actual = f(x, y, z).value

print('original {0:.5f} predicted {1:.5f} actual {2:.5f}'.format(
       original, predicted, actual))
```

    original 0.27513 predicted 0.22709 actual 0.22942