# Part 0: Hello Qiskit

While skip talking about how a quantum computer is important and hot in recent days, let's jump into the quantum circuit directly by using Qiskit.

Qiskit is an open-source SDK for working with quantum computers at the level of pulses, circuits, and algorithms. Qiskit supports many quantum backends - IBM Quantum, IonQ, AQT, Honeywell and etc and quantum cloud - IBM Quantum Experience, Microsoft Azure Quantum. Qiskit's great contribution to the quantum computing ecosystem is its huge and active developer community and we will talk about this at the 29th Oct lecture.

With this 1st lecture, we will demonstrate:

- Part 1: Qiskit Basic (10min)
    - Basic Quantum Gates and Operations
    - Exercise: Quantum State manipulating with the gates - Bell State
- Part 2: Tutorial with GHZ Circuit (30 min)
    - Compose 3-qubits GHZ quantum circuits
    - simulating them on various backends, and visualizing the results
- Part 3: Introduction of the Quantum Information Theory (30min)
    - Phase Kickback
    - the Deutsch Jozsa algorithm
- Exercise (20min):
    - explore easy physics model by using qiskit with assignment1

# Part1 : Qiskit Basic - Basic Quantum Gates and Operations

## Single Qubit Quantum states

A single qubit quantum state can be written as

$$\left|\psi\right\rangle = \alpha\left|0\right\rangle + \beta \left|1\right\rangle$$

where $\alpha$ and $\beta$ are complex numbers. In a measurement the probability of the bit being in $\left|0\right\rangle$ is $|\alpha|^2$ and $\left|1\right\rangle$ is $|\beta|^2$. As a vector this is

$$
\left|\psi\right\rangle =
\begin{pmatrix}
\alpha \\
\beta
\end{pmatrix}.
$$

Note, due to the conservation of probability $|\alpha|^2+ |\beta|^2 = 1$ and since global phase is undetectable $\left|\psi\right\rangle := e^{i\delta} \left|\psi\right\rangle$ we only require two real numbers to describe a single qubit quantum state.

A convenient representation is

$$\left|\psi\right\rangle = \cos(\theta/2)\left|0\right\rangle + \sin(\theta/2)e^{i\phi}\left|1\right\rangle$$

where $0\leq \phi < 2\pi$, and $0\leq \theta \leq \pi$.  From this, it is clear that there is a one-to-one correspondence between qubit states ($\mathbb{C}^2$) and the points on the surface of a unit sphere ($\mathbb{R}^3$). This is called the Bloch sphere representation of a qubit state.

Quantum gates/operations are usually represented as matrices. A gate which acts on a qubit is represented by a $2\times 2$ unitary matrix $U$. The action of the quantum gate is found by multiplying the matrix representing the gate with the vector which represents the quantum state.

$$\left|\psi'\right\rangle = U\left|\psi\right\rangle$$

A general unitary must be able to take the $\left|0\right\rangle$ to the above state. That is

$$
U = \begin{pmatrix}
\cos(\theta/2) & a \\
e^{i\phi}\sin(\theta/2) & b
\end{pmatrix}
$$

where $a$ and $b$ are complex numbers constrained such that $U^\dagger U = I$ for all $0\leq\theta\leq\pi$ and $0\leq \phi<2\pi$. This gives 3 constraints and as such $a\rightarrow -e^{i\lambda}\sin(\theta/2)$ and $b\rightarrow e^{i\lambda+i\phi}\cos(\theta/2)$ where $0\leq \lambda<2\pi$ giving

$$
U = \begin{pmatrix}
\cos(\theta/2) & -e^{i\lambda}\sin(\theta/2) \\
e^{i\phi}\sin(\theta/2) & e^{i\lambda+i\phi}\cos(\theta/2)
\end{pmatrix}.
$$

This is the most general form of a single qubit unitary.

Qiskit support many single-qubi gates. You can check the whole list of them at here:
https://qiskit.org/textbook/ch-states/single-qubit-gates.html

The most frequent used  and famouse gate and it's matrix forms are examine below:

```python
from qiskit import QuantumCircuit, execute, Aer, IBMQ
from qiskit.visualization import *
from qiskit.quantum_info import state_fidelity

import numpy as np

#ignore warnings
import warnings
warnings.filterwarnings('ignore')

# Magic function to render plots
%matplotlib inline
```

#### u gates

In Qiskit we give you access to the general unitary using the $u3$ gate

$$
u3(\theta, \phi, \lambda) = U(\theta, \phi, \lambda)
$$

```python
u_gate = QuantumCircuit(1)
u_gate.u(np.pi/2,np.pi/2,np.pi/2,0)
u_gate.draw()
```

### Pauli gates

#### $X$: bit-flip gate

The bit-flip gate $X$ is defined as:

$$
X   =
\begin{pmatrix}
0 & 1\\
1 & 0
\end{pmatrix}= u3(\pi,0,\pi)
$$

```python
x_gate=QuantumCircuit(1) # Create a quantum circuit with 1 qubit
x_gate.x(0)
x_gate.draw(output='mpl')
```

#### $Y$: bit- and phase-flip gate

The $Y$ gate is defined as:

$$
Y  =
\begin{pmatrix}
0 & -i\\
i & 0
\end{pmatrix}=u3(\pi,\pi/2,\pi/2)
$$

```python
y_gate = QuantumCircuit(1)
y_gate.y(0)
y_gate.draw(output='mpl')
```

#### $Z$: phase-flip gate

The phase-flip gate $Z$ is defined as:

$$
Z =
\begin{pmatrix}
1 & 0\\
0 & -1
\end{pmatrix}=u1(\pi)
$$

```python
z_gate = QuantumCircuit(1)
z_gate.z(0)
z_gate.draw(output='mpl')
```

### Clifford gates
the Clifford gates are the elements of the Clifford group, a set of mathematical transformations which effect permutations of the Pauli operators.

#### Hadamard gate

A Hadamard gate represents a rotation of $\pi$ about the axis that is in the middle of the X-axis and Z-axis.
It maps the basis state $|0\rangle$ to $\frac{|0\rangle + |1\rangle}{\sqrt{2}}$, which means that a measurement will have equal probabilities of being `1` or `0`, creating a 'superposition' of states. This state is also written as $|+\rangle$. What the Hadamard does is to transform between the $|0\rangle$ $|1\rangle$ and the $|+\rangle$ $|-\rangle$ base.

$$
H =
\frac{1}{\sqrt{2}}
\begin{pmatrix}
1 & 1\\
1 & -1
\end{pmatrix}= u2(0,\pi)
$$

```python
# Let's do an H-gate on a |0> qubit
h_gate = QuantumCircuit(1)
h_gate.h(0)
h_gate.draw(output='mpl')
```

#### $S$ (or, $\sqrt{Z}$ phase) gate

$$
S =
\begin{pmatrix}
1 & 0\\
0 & i
\end{pmatrix}= u1(\pi/2)
$$

```python
s_gate = QuantumCircuit(1)
s_gate.s(0)
s_gate.draw(output='mpl')
```

#### $S^{\dagger}$ (or, conjugate of $\sqrt{Z}$ phase) gate

$$
S^{\dagger} =
\begin{pmatrix}
1 & 0\\
0 & -i
\end{pmatrix}= u1(-\pi/2)
$$

```python
sdg_gate = QuantumCircuit(1)
sdg_gate.sdg(0)
sdg_gate.draw(output='mpl')
```

### Standard Rotations

The standard rotation gates are those that define rotations around the Paulis $P=\{X,Y,Z\}$. They are defined as

$$ R_P(\theta) = \exp(-i \theta P/2) = \cos(\theta/2)I -i \sin(\theta/2)P$$

#### Rotation around X-axis

$$
R_x(\theta) =
\begin{pmatrix}
\cos(\theta/2) & -i\sin(\theta/2)\\
-i\sin(\theta/2) & \cos(\theta/2)
\end{pmatrix} = u3(\theta, -\pi/2,\pi/2)
$$

```python
rx_gate = QuantumCircuit(1)
rx_gate.rx(np.pi/2,0)
rx_gate.draw(output='mpl')
```

#### Rotation around Y-axis

$$
R_y(\theta) =
\begin{pmatrix}
\cos(\theta/2) & - \sin(\theta/2)\\
\sin(\theta/2) & \cos(\theta/2).
\end{pmatrix} =u3(\theta,0,0)
$$

```python
ry_gate = QuantumCircuit(1)
ry_gate.ry(np.pi/2,0)
ry_gate.draw(output='mpl')
```

#### Rotation around Z-axis

$$
R_z(\phi) =
\begin{pmatrix}
e^{-i \phi/2} & 0 \\
0 & e^{i \phi/2}
\end{pmatrix}\equiv u1(\phi)
$$

Note that here we have used an equivalent as it is different to u1 by a global phase $e^{-i \phi/2}$.

```python
rz_gate = QuantumCircuit(1)
rz_gate.rz(np.pi/2,0)
rz_gate.draw(output='mpl')
```

## Multi-Qubit Gates

### Mathematical Preliminaries

The space of a quantum computer grows exponentially with the number of qubits. For $n$ qubits the complex vector space has dimension $d=2^n$. To describe states of a multi-qubit system, the tensor product is used to "glue together" operators and basis vectors.

Let's start by considering a 2-qubit system. Given two operators $A$ and $B$ that each act on one qubit, the joint operator $A \otimes B$ acting on two qubits is

$$\begin{equation}
	A\otimes B =
	\begin{pmatrix}
		A_{00} \begin{pmatrix}
			B_{00} & B_{01} \\
			B_{10} & B_{11}
		\end{pmatrix} & A_{01} 	\begin{pmatrix}
				B_{00} & B_{01} \\
				B_{10} & B_{11}
			\end{pmatrix} \\
		A_{10} 	\begin{pmatrix}
					B_{00} & B_{01} \\
					B_{10} & B_{11}
				\end{pmatrix} & A_{11} 	\begin{pmatrix}
							B_{00} & B_{01} \\
							B_{10} & B_{11}
						\end{pmatrix}
	\end{pmatrix},
\end{equation}$$

where $A_{jk}$ and $B_{lm}$ are the matrix elements of $A$ and $B$, respectively.

Analogously, the basis vectors for the 2-qubit system are formed using the tensor product of basis vectors for a single qubit:
$$\begin{equation}\begin{split}
	\left|{00}\right\rangle &= \begin{pmatrix}
		1 \begin{pmatrix}
			1  \\
			0
		\end{pmatrix} \\
		0 \begin{pmatrix}
			1  \\
			0
		\end{pmatrix}
	\end{pmatrix} = \begin{pmatrix} 1 \\ 0 \\ 0 \\0 \end{pmatrix}~~~\left|{01}\right\rangle = \begin{pmatrix}
	1 \begin{pmatrix}
	0 \\
	1
	\end{pmatrix} \\
	0 \begin{pmatrix}
	0  \\
	1
	\end{pmatrix}
	\end{pmatrix} = \begin{pmatrix}0 \\ 1 \\ 0 \\ 0 \end{pmatrix}\end{split}
\end{equation}$$

$$\begin{equation}\begin{split}\left|{10}\right\rangle = \begin{pmatrix}
	0\begin{pmatrix}
	1  \\
	0
	\end{pmatrix} \\
	1\begin{pmatrix}
	1 \\
	0
	\end{pmatrix}
	\end{pmatrix} = \begin{pmatrix} 0 \\ 0 \\ 1 \\ 0 \end{pmatrix}~~~ 	\left|{11}\right\rangle = \begin{pmatrix}
	0 \begin{pmatrix}
	0  \\
	1
	\end{pmatrix} \\
	1\begin{pmatrix}
	0  \\
	1
	\end{pmatrix}
	\end{pmatrix} = \begin{pmatrix} 0 \\ 0 \\ 0 \\1 \end{pmatrix}\end{split}
\end{equation}.$$

Note we've introduced a shorthand for the tensor product of basis vectors, wherein $\left|0\right\rangle \otimes \left|0\right\rangle$ is written as $\left|00\right\rangle$. The state of an $n$-qubit system can be described using the $n$-fold tensor product of single-qubit basis vectors. Notice that the basis vectors for a 2-qubit system are 4-dimensional; in general, the basis vectors of an $n$-qubit sytsem are $2^{n}$-dimensional, as noted earlier.

### Basis vector ordering in Qiskit

Within the physics community, the qubits of a multi-qubit systems are typically ordered with the first qubit on the left-most side of the tensor product and the last qubit on the right-most side. For instance, if the first qubit is in state $\left|0\right\rangle$ and second is in state $\left|1\right\rangle$, their joint state would be $\left|01\right\rangle$. Qiskit uses a slightly different ordering of the qubits, in which the qubits are represented from the most significant bit (MSB) on the left to the least significant bit (LSB) on the right (big-endian). This is similar to bitstring representation on classical computers, and enables easy conversion from bitstrings to integers after measurements are performed. For the example just given, the joint state would be represented as $\left|10\right\rangle$. Importantly, *this change in the representation of multi-qubit states affects the way multi-qubit gates are represented in Qiskit*, as discussed below.

The representation used in Qiskit enumerates the basis vectors in increasing order of the integers they represent. For instance, the basis vectors for a 2-qubit system would be ordered as $\left|00\right\rangle$, $\left|01\right\rangle$, $\left|10\right\rangle$, and $\left|11\right\rangle$. Thinking of the basis vectors as bit strings, they encode the integers 0,1,2 and 3, respectively.

### Controlled operations on qubits

A common multi-qubit gate involves the application of a gate to one qubit, conditioned on the state of another qubit. For instance, we might want to flip the state of the second qubit when the first qubit is in $\left|0\right\rangle$. Such gates are known as _controlled gates_. The standard multi-qubit gates consist of two-qubit gates and three-qubit gates. The two-qubit gates are:
- controlled Pauli gates
- controlled Hadamard gate
- controlled rotation gates
- controlled phase gate
- controlled u3 gate
- swap gate

The three-qubit gates are:
- Toffoli gate
- Fredkin gate

## Two-qubit gates

Most of the two-qubit gates are of the controlled type (the SWAP gate being the exception). In general, a controlled two-qubit gate $C_{U}$ acts to apply the single-qubit unitary $U$ to the second qubit when the state of the first qubit is in $\left|1\right\rangle$. Suppose $U$ has a matrix representation

$$U = \begin{pmatrix} u_{00} & u_{01} \\ u_{10} & u_{11}\end{pmatrix}.$$

We can work out the action of $C_{U}$ as follows. Recall that the basis vectors for a two-qubit system are ordered as $\left|00\right\rangle, \left|01\right\rangle, \left|10\right\rangle, \left|11\right\rangle$. Suppose the **control qubit** is **qubit 0** (which, according to Qiskit's convention, is one the _right-hand_ side of the tensor product). If the control qubit is in $\left|1\right\rangle$, $U$ should be applied to the **target** (qubit 1, on the _left-hand_ side of the tensor product). Therefore, under the action of $C_{U}$, the basis vectors are transformed according to

$$\begin{align*}
C_{U}: \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle} &\rightarrow \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle}\\
C_{U}: \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle} &\rightarrow \underset{\text{qubit}~1}{U\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle}\\
C_{U}: \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle} &\rightarrow \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle}\\
C_{U}: \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle} &\rightarrow \underset{\text{qubit}~1}{U\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle}\\
\end{align*}.$$

In matrix form, the action of $C_{U}$ is

$$\begin{equation}
	C_U = \begin{pmatrix}
	1 & 0 & 0 & 0 \\
	0 & u_{00} & 0 & u_{01} \\
	0 & 0 & 1 & 0 \\
	0 & u_{10} &0 & u_{11}
		\end{pmatrix}.
\end{equation}$$

To work out these matrix elements, let

$$C_{(jk), (lm)} = \left(\underset{\text{qubit}~1}{\left\langle j \right|} \otimes \underset{\text{qubit}~0}{\left\langle k \right|}\right) C_{U} \left(\underset{\text{qubit}~1}{\left| l \right\rangle} \otimes \underset{\text{qubit}~0}{\left| k \right\rangle}\right),$$

compute the action of $C_{U}$ (given above), and compute the inner products.

As shown in the examples below, this operation is implemented in Qiskit as `cU(q[0],q[1])`.

If **qubit 1 is the control and qubit 0 is the target**, then the basis vectors are transformed according to
$$\begin{align*}
C_{U}: \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle} &\rightarrow \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle}\\
C_{U}: \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle} &\rightarrow \underset{\text{qubit}~1}{\left|0\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle}\\
C_{U}: \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{\left|0\right\rangle} &\rightarrow \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{U\left|0\right\rangle}\\
C_{U}: \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{\left|1\right\rangle} &\rightarrow \underset{\text{qubit}~1}{\left|1\right\rangle}\otimes \underset{\text{qubit}~0}{U\left|1\right\rangle}\\
\end{align*},$$

which implies the matrix form of $C_{U}$ is
$$\begin{equation}
	C_U = \begin{pmatrix}
	1 & 0 & 0  & 0 \\
	0 & 1 & 0 & 0 \\
	0 & 0 & u_{00} & u_{01} \\
	0 & 0 & u_{10} & u_{11}
		\end{pmatrix}.
\end{equation}$$

### Controlled Pauli Gates

#### Controlled-X (or, controlled-NOT) gate
The controlled-not gate flips the `target` qubit when the control qubit is in the state $\left|1\right\rangle$. If we take the MSB as the control qubit (e.g. `cx(q[1],q[0])`), then the matrix would look like

$$
C_X =
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & 1 & 0 & 0\\
0 & 0 & 0 & 1\\
0 & 0 & 1 & 0
\end{pmatrix}.
$$

However, when the LSB is the control qubit, (e.g. `cx(q[0],q[1])`), this gate is equivalent to the following matrix:

$$
C_X =
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & 0 & 0 & 1\\
0 & 0 & 1 & 0\\
0 & 1 & 0 & 0
\end{pmatrix}.
$$

```python
cx_gate = QuantumCircuit(2)
cx_gate.cx(0,1)
cx_gate.draw(output='mpl')
```

#### Controlled $Y$ gate

Apply the $Y$ gate to the target qubit if the control qubit is the MSB

$$
C_Y =
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & 1 & 0 & 0\\
0 & 0 & 0 & -i\\
0 & 0 & i & 0
\end{pmatrix},
$$

or when the LSB is the control

$$
C_Y =
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & 0 & 0 & -i\\
0 & 0 & 1 & 0\\
0 & i & 0 & 0
\end{pmatrix}.
$$

```python
cy_gate = QuantumCircuit(2)
cy_gate.cy(0,1)
cy_gate.draw(output='mpl')
```

#### Controlled $Z$ (or, controlled Phase) gate

Similarly, the controlled Z gate flips the phase of the target qubit if the control qubit is $\left|1\right\rangle$. The matrix looks the same regardless of whether the MSB or LSB is the control qubit:

$$
C_Z =
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & 1 & 0 & 0\\
0 & 0 & 1 & 0\\
0 & 0 & 0 & -1
\end{pmatrix}
$$

```python
cz_gate = QuantumCircuit(2)
cz_gate.cz(0,1)
cz_gate.draw(output='mpl')
```

### Controlled $u3$ rotation

Perform controlled-$u3$ rotation on the target qubit if the control qubit (here LSB) is $\left|1\right\rangle$.

$$
C_{u3}(\theta, \phi, \lambda) \equiv
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & e^{-i(\phi+\lambda)/2}\cos(\theta/2) & 0 & -e^{-i(\phi-\lambda)/2}\sin(\theta/2)\\
0 & 0 & 1 & 0\\
0 & e^{i(\phi-\lambda)/2}\sin(\theta/2) & 0 & e^{i(\phi+\lambda)/2}\cos(\theta/2)
\end{pmatrix}.
$$

```python
cu_gate = QuantumCircuit(2)
cu_gate.cu3(np.pi/2, np.pi/2, np.pi/2, 0, 1)
cu_gate.draw(output='mpl')
```

### SWAP gate

The SWAP gate exchanges the two qubits. It transforms the basis vectors as

$$\left|00\right\rangle \rightarrow \left|00\right\rangle~,~\left|01\right\rangle \rightarrow \left|10\right\rangle~,~\left|10\right\rangle \rightarrow \left|01\right\rangle~,~\left|11\right\rangle \rightarrow \left|11\right\rangle,$$

which gives a matrix representation of the form

$$
\mathrm{SWAP} =
\begin{pmatrix}
1 & 0 & 0 & 0\\
0 & 0 & 1 & 0\\
0 & 1 & 0 & 0\\
0 & 0 & 0 & 1
\end{pmatrix}.
$$

```python
swap_gate = QuantumCircuit(2)
swap_gate.swap(0, 1)
swap_gate.draw(output='mpl')
```

## Non-unitary operations

Now that we have gone through all the unitary operations in quantum circuits, we also have access to non-unitary operations. These include measurements, reset of qubits, and classical conditional operations.

### Measurements

We don't have access to all the information when we make a measurement in a quantum computer. The quantum state is projected onto the standard basis. Below are two examples showing a circuit that is prepared in a basis state and the quantum computer prepared in a superposition state.

```python
qc = QuantumCircuit(2)
qc.measure_all()
qc.draw(output='mpl')
```

### Reset
It is also possible to `reset` qubits to the $\left|0\right\rangle$ state in the middle of computation. Note that `reset` is not a Gate operation, since it is irreversible.

```python
qc = QuantumCircuit(1)
qc.x(0)
qc.reset(0)
qc.measure_all()
qc.draw(output='mpl')
```

### Conditional operations
It is also possible to do operations conditioned on the state of the classical register

```python
from qiskit import ClassicalRegister, QuantumRegister

q = QuantumRegister(1)
c = ClassicalRegister(1)
qc = QuantumCircuit(q,c)
qc.x(q[0]).c_if(c, 0)
qc.measure(0,0)
qc.draw(output='mpl')
```

## Arbitrary initialization
What if we want to initialize a qubit register to an arbitrary state? An arbitrary state for $n$ qubits may be specified by a vector of $2^n$ amplitudes, where the sum of amplitude-norms-squared equals 1. For example, the following three-qubit state can be prepared:

$$\left|\psi\right\rangle = \frac{i}{4}\left|000\right\rangle + \frac{1}{\sqrt{8}}\left|001\right\rangle + \frac{1+i}{4}\left|010\right\rangle + \frac{1+2i}{\sqrt{8}}\left|101\right\rangle + \frac{1}{4}\left|110\right\rangle$$

```python
# Initializing a three-qubit quantum state
import math
desired_vector = [
    1 / math.sqrt(16) * complex(0, 1),
    1 / math.sqrt(8) * complex(1, 0),
    1 / math.sqrt(16) * complex(1, 1),
    0,
    0,
    1 / math.sqrt(8) * complex(1, 2),
    1 / math.sqrt(16) * complex(1, 0),
    0]

init_qc = QuantumCircuit(3)
init_qc.initialize(desired_vector, [0,1,2])
init_qc.draw(output='mpl')
```

# Part1 Exercise: Quantum State manipulating with the gates - Bell State

There are four Bell states given by

$$ |\Phi^+\rangle = \frac{1}{\sqrt{2}}\left(|0\rangle\otimes |0\rangle + |1\rangle\otimes|1\rangle\right)$$
$$ |\Phi^-\rangle = \frac{1}{\sqrt{2}}\left(|0\rangle\otimes |0\rangle - |1\rangle\otimes|1\rangle\right)$$
$$ |\Psi^+\rangle = \frac{1}{\sqrt{2}} \left(|0\rangle\otimes |1\rangle + |1\rangle\otimes|0\rangle\right)$$
$$ |\Psi^-\rangle = \frac{1}{\sqrt{2}} \left(|0\rangle\otimes |1\rangle - |1\rangle\otimes|0\rangle\right)$$

Can you find circuits preparing all these four Bell states? Convince yourself that the Bell states are indeed orthogonal using the state_vector backend of Aer. We will see more details on running quantum circuit at Part2 so at here, just try to use below function to run the circuit.

```python
# We prepare a similar function for running on the state vector simulator
def circuit_run(quantum_circuit, decimals=6):
    """Takes a circuit, and runs it on the state vector simulator backend."""
    statevector_simulator = Aer.get_backend('statevector_simulator')
    job = execute(quantum_circuit, backend=statevector_simulator)
    result = job.result()
    statevector = result.get_statevector(quantum_circuit, decimals=decimals)
    return statevector
```

### The state $|\Phi^+\rangle$

|$\Phi^+$> = [0.707107+0.j 0.      +0.j 0.      +0.j 0.707107+0.j]

```python
qc_phi_plus = QuantumCircuit(2)

#your code here

qc_phi_plus.draw(output='mpl')
```

```python
phi_plus_state = circuit_run(qc_phi_plus)
print('|Phi^+> =', phi_plus_state)
```

### The state $|\Phi^-\rangle$
|$\Phi^-$> = [ 0.707107+0.j  0.      +0.j  0.      +0.j -0.707107-0.j]

```python
# Let us first prepare the Phi^- state
qc_phi_minus = QuantumCircuit(2)

#your code here

qc_phi_minus.draw(output='mpl')
```

```python
phi_minus_state = circuit_run(qc_phi_minus)
print('|Phi^-> =', phi_minus_state)
```

### The state $|\Psi^+\rangle$
|$\Psi^+$> = [0.      +0.j 0.707107+0.j 0.707107+0.j 0.      +0.j]

```python
#The Psi^+ state
qc_psi_plus = QuantumCircuit(2)

#your code here

qc_psi_plus.draw(output='mpl')
```

```python
psi_plus_state = circuit_run(qc_psi_plus)
print('|Psi^+> =', psi_plus_state)
```

### The state $|\Psi^-\rangle$
|$\Psi^-$> = [ 0.      +0.j -0.707107-0.j  0.707107+0.j  0.      +0.j]

```python
# Let us first prepare the Psi^- state
qc_psi_minus = QuantumCircuit(2)

#your code here

qc_psi_minus.draw(output='mpl')
```

```python
psi_minus_state = circuit)run(qc_psi_minus)
print('|Psi^-> =', psi_minus_state)
```

```python
### Check the orthogonality of the Bell states
print('|<Phi^+|Phi^+>|^2 =', state_fidelity(phi_plus_state, phi_plus_state))
print('|<Phi^+|Phi^->|^2 =', state_fidelity(phi_plus_state, phi_minus_state))
print('|<Phi^+|Psi^+>|^2 =', state_fidelity(phi_plus_state, psi_plus_state))
print('|<Phi^+|Psi^->|^2 =', state_fidelity(phi_plus_state, psi_minus_state))

print('|<Psi^+|Phi^+>|^2 =', state_fidelity(psi_plus_state, phi_plus_state))
print('|<Psi^+|Phi^->|^2 =', state_fidelity(psi_plus_state, phi_minus_state))
print('|<Psi^+|Psi^+>|^2 =', state_fidelity(psi_plus_state, psi_plus_state))
print('|<Psi^+|Psi^->|^2 =', state_fidelity(psi_plus_state, psi_minus_state))

```

```python
import qiskit.tools.jupyter
%qiskit_version_table
%qiskit_copyright
```

```python

```

Lecture1/Lecture 1 - Intro to QC for the physicist Part0 Part1.ipynb


# 2.6 Statistical Models, Supervised Learning and Function Approximation

Our goal is to find a useful approximation $\hat{f(x)}$ to the function $f(x)$ that underlies the predictive relationship between the inputs and outputs.

- We saw that squared error loss lead us to the regression function $f(x)=E(Y|X = x)$ for a qualitive response.

- The nearest-neighbor methods estimates directly the conditional expections, but may result in large errors for the high dimension input spaces. (*The curse of dimensionality*)

We anticipate using other classes of models for f(x) to overcome the dimensionality problems.

### 2.6.1 A Statistical Model for the Joint Distribution Pr(X, Y)

(2.29) Suppose that our data arose from a statistical model: $$Y = f(X) + \varepsilon$$

where the random error $\varepsilon$ has $E(\varepsilon) = 0$ and independent of X.

The additive error model is a useful approximation to the truth. Generally there will be unmeasured variables that also contribute to the output, including measurement error. The additive model assumes that can capture all departures via the error $\varepsilon$.

The assumption in (2.29) that the errors are independent and identically distributed is not strictly necessary. For example, simple modifications can be made to avoid the independence assumption, e.g $Var(Y| X = x) = \sigma(x)$, and now both the mean and variance depend on X.

### 2.6.2 Supervised Learning

Suppose that errors are additive and that the model $Y = f(X) + \varepsilon$. Supervised learning attempts to learn $f$ through a teacher and learns by examples (i.e by a training set of observations ($\mathcal{T} = (x_i, y_i), i = 1...N$)

### 2.6.3 Function Approximation

The goal is to obtain a useful approximation to f(x) for all x in some region of $\mathbb{R}^p$, given the representations in $\mathcal{T}$.

Many of the approximations have associated a set of parameters $\theta$ that can be modified to suit the data at hand, e.g the linear model $f(x)=x^T\beta$ has $\theta=\beta$. Another class of useful approximators can be expressed as *linear basis expansions*:

$$f_\theta(x) = \sum_{k=1}^{K}h_k(x)\theta_k$$

where the $h_k$ are a suitable set of functions or transformations of the input vector x. We also encounter *nonlinear expansions*, such as the sigmoid transformation:

$$h_k(x) = \frac{1}{1+exp(-x^T\beta_k)}$$

We can use least squares to estimate the parameters $\theta$ in $f_\theta$, by minimizing the residual sum-of-squares:

$$RSS(\theta)=\sum_{i=1}^N(y_i - f_\theta(x_i)) ^ 2$$

While least squares is very convenient, it is not only criterion used and in some cases would not make sense. A more general principle for estimation is *maximum likelihood estimation*. Suppose we have a random sample $y_i$, i = 1...N from a density $Pr_\theta(y)$ indexed by some parameters $\theta.$ The log-probability of the observed sample is:
$$L(\theta)=\sum_{i=1}^N logPr_\theta(y_i)$$

Least squares for the additive error model $Y = f_\theta(X)+\varepsilon$, with $\varepsilon \sim N(0, \sigma^2)$ is equivalent to maximum likelihood using the conditional likelihood

$$Pr(Y|X, \theta)=N(f_\theta(X), \sigma^2)$$

The log-likelihood of the data is:
$$
\begin{align}
L(\theta) &= \sum_{i=1}^N log \left(\frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{(y_i-f_\theta(x_i))^2}{2\sigma^2}}\right)\\
&= \sum_{i=1}^N log \left(\frac{1}{\sqrt{2\pi\sigma^2}}\right) - \sum_{i=1}^N(\frac{(y_i-f_\theta(x_i))^2}{2\sigma^2})\\
&= \sum_{i=1}^N log \left((2\pi\sigma^2)^{-\frac{1}{2}}\right) - \frac{1}{2\sigma^2}\sum_{i=1}^N(y_i-f_\theta(x_i))^2\\
&= \sum_{i=1}^N \left(-\frac{1}{2}log(2\pi)-log(\sigma)\right) - \frac{1}{2\sigma^2}\sum_{i=1}^N(y_i-f_\theta(x_i))^2\\
&= \frac{N}{2}log(2\pi)-Nlog(\sigma) - \frac{1}{2\sigma^2}\sum_{i=1}^N(y_i-f_\theta(x_i))^2
\end{align}
$$

and the only term involving $\theta$ is the last.

A more interesting example is the multinomial likelihood for the regression function Pr(G|X) for a qualitative output G. Suppose we have a model $Pr(G = \mathcal{G}_k| X = x) = p_{k, \theta}(x), k = 1...K$ indexed by $\theta$. Then the log-likelihood (a.k.a the cross-entropy) is :

$$L(\theta)=\sum_{i=1}^N log (p_{g_i, \theta}(x_i))$$


# YOUR PROJECT TITLE

> **Note the following:**
> 1. This is *not* meant to be an example of an actual **model analysis project**, just an example of how to structure such a project.
> 1. Remember the general advice on structuring and commenting your code from [lecture 5](https://numeconcopenhagen.netlify.com/lectures/Workflow_and_debugging).
> 1. Remember this [guide](https://www.markdownguide.org/basic-syntax/) on markdown and (a bit of) latex.
> 1. Turn on automatic numbering by clicking on the small icon on top of the table of contents in the left sidebar.
> 1. The `modelproject.py` file includes a function which could be used multiple times in this notebook.

Imports and set magics:

```python
import numpy as np
from scipy import optimize
import sympy as sm

# autoreload modules when code is run
%load_ext autoreload
%autoreload 2

# local modules
import modelproject
```

# Model description

> **Note:** If you are not that friendly with markdown and latex you can refer to an external document with the model description instead.

We consider the **standard Solow-model** where:

1. $K_t$ is capital
* $L_t$ is labor (growing with a constant rate of $n$)
* $A_t$ is technology (growing with a constant rate of $g$)
* $Y_t = F(K_t,A_tL_t)$ is GDP

**Saving** is a constant fraction of GDP

$$ S_t = sY_t,\,s\in(0,1) $$

such that **capital accumulates** according to

$$ K_{t+1}=S_{t}+(1-\delta)K_{t}=sF(K_{t},A_{t}L_{t})+(1-\delta)K_{t}, \delta \in (0,1) $$

The **production function** has **constant-return to scale** such that

$$ \frac{Y_{t}}{A_{t}L_{t}}=\frac{F(K_{t},A_{t}L_{t})}{A_{t}L_{t}}=F(\tilde{k}_{t},1)\equiv f(\tilde{k}_{t}) $$

where $\tilde{k}_t = \frac{K_t}{A_{t}L_{t}}$ is the technology adjusted capital-labor ratio.

The **transition equation** then becomes

$$ \tilde{k}_{t+1}= \frac{1}{(1+n)(1+g)}[sf(\tilde{k}_{t})+(1-\delta)\tilde{k}_{t}] $$

We assume the **production function** is **Cobb-Douglas** so

$$ F(K_{t},A_{t}L_{t})=K_{t}^{\alpha}(A_{t}L_{t})^{1-\alpha}\Rightarrow f(\tilde{k}_{t})=\tilde{k}_{t}^{\alpha} $$

# Steady state

## Analytical solution

We can use **sympy** to find an analytical expression for the steady state, i.e. solve

$$ \tilde{k}^{\ast}= \frac{1}{(1+n)(1+g)}[sf(\tilde{k}^{\ast})+(1-\delta)\tilde{k}^{\ast}] $$

First we define all **symbols**:

```python
k = sm.symbols('k')
alpha = sm.symbols('alpha')
delta = sm.symbols('delta')
s = sm.symbols('s')
g = sm.symbols('g')
n = sm.symbols('n')
```

Then we define the **steady state equation**

```python
ss = sm.Eq(k,(s*k**alpha+(1-delta)*k)/((1+n)*(1+g)))
```

and **solve** it

```python
kss = sm.solve(ss,k)[0]
kss
```

$\displaystyle \left(\frac{\delta + g n + g + n}{s}\right)^{\frac{1}{\alpha - 1}}$

For later use, we turn the solution into a **Python funciton**

```python
ss_func = sm.lambdify((s,g,n,alpha,delta),kss)
```

## Numerical solution

We can re-write the equation for the steady state capital per capita as

\\[ 0 = \frac{1}{(1+n)(1+g)}[sf(\tilde{k}^{\ast})+(1-\delta)\tilde{k}^{\ast}] - \tilde{k}^{\ast} \\]

whereby it clearly becomes a **root-finding problem**. Such a problem can be solved by a **bisection method**.

Define the model **parameters**:

```python
s = 0.2
g = 0.02
n = 0.01
alpha = 1/3
delta = 0.1
```

**Solve numerically** for the steady state:

```python
solution = modelproject.solve_for_ss(s,g,n,alpha,delta)

print(f'analytical solution is: {ss_func(s,g,n,alpha,delta):.3f}')
print(f' numerical solution is: {solution.root:.3f}')
```

    analytical solution is: 1.904
     numerical solution is: 1.904

# Further analysis

ADD FURTHER ANALYSIS, VISUALIZATIONS AND EXTENSIONS.

# Conclusion

ADD CONCISE CONCLUSION.


# Tutorial and example about approx. ODE with Euler Method

```python
import numpy as np
import math
```

- __define__ $f=(t,y)$
- __input__ $t_{0}$ and $y_{0}$
- __input__ step size, $h$ and number of steps $n$
- __for__ $j$ from 1 to $n$:
    - $m=f(t_{0},y_{0})$
    - $y_{1}$ = $y_{0}+h*m$
    - $t_{1}=t_{0}+h$
    - Print $t_{1}$ and $y_{1}$
    - $t_{0}=t_{1}$
    - $y_{0}=y_{1}$

```python
def euler(givenfunction,t0,y0,h,n,i):
    m=givenfunction(t0,y0)
    y1=y0+h*m
    t1=t0+h
    i=i+1
    if i==n:
        print('t= %.2f'%t1,'approx= %.6f'%y1)
        times.append(round(t1,2))
        results.append(round(y1,5))
        return (t1,y1)
    else:
        print('t= %.2f'%t1,'approx= %.6f'%y1)
        times.append(round(t1,2))
        results.append(round(y1,5))
        return euler(givenfunction,t1,y1,h,n,i)

```

For the given equation and initial condition
\begin{equation}
y^{\prime}+2 y=2-\mathbf{e}^{-4 t} \quad y(0)=1
\end{equation}
Pick a $h$ and values of $t$,

```python
def func(t,y):
    y_prime = 2-math.exp(-4*t)-2*y
    return y_prime
```

```python
h=0.1
t0=0
y0=1
n=50
times=[t0]
results=[y0]

```

```python
x=euler(func,t0,y0,h,n,i=1)
```

    t= 0.10 approx= 0.900000
    t= 0.20 approx= 0.852968
    t= 0.30 approx= 0.837441
    t= 0.40 approx= 0.839834
    t= 0.50 approx= 0.851677
    t= 0.60 approx= 0.867808
    t= 0.70 approx= 0.885175
    t= 0.80 approx= 0.902059
    t= 0.90 approx= 0.917571
    t= 1.00 approx= 0.931324
    t= 1.10 approx= 0.943228
    t= 1.20 approx= 0.953355
    t= 1.30 approx= 0.961861
    t= 1.40 approx= 0.968937
    t= 1.50 approx= 0.974780
    t= 1.60 approx= 0.979576
    t= 1.70 approx= 0.983495
    t= 1.80 approx= 0.986684
    t= 1.90 approx= 0.989273
    t= 2.00 approx= 0.991368
    t= 2.10 approx= 0.993061
    t= 2.20 approx= 0.994426
    t= 2.30 approx= 0.995526
    t= 2.40 approx= 0.996411
    t= 2.50 approx= 0.997122
    t= 2.60 approx= 0.997693
    t= 2.70 approx= 0.998151
    t= 2.80 approx= 0.998519
    t= 2.90 approx= 0.998814
    t= 3.00 approx= 0.999050
    t= 3.10 approx= 0.999239
    t= 3.20 approx= 0.999391
    t= 3.30 approx= 0.999513
    t= 3.40 approx= 0.999610
    t= 3.50 approx= 0.999688
    t= 3.60 approx= 0.999750
    t= 3.70 approx= 0.999800
    t= 3.80 approx= 0.999840
    t= 3.90 approx= 0.999872
    t= 4.00 approx= 0.999898
    t= 4.10 approx= 0.999918
    t= 4.20 approx= 0.999934
    t= 4.30 approx= 0.999948
    t= 4.40 approx= 0.999958
    t= 4.50 approx= 0.999966
    t= 4.60 approx= 0.999973
    t= 4.70 approx= 0.999979
    t= 4.80 approx= 0.999983
    t= 4.90 approx= 0.999986

Analytical solution
\begin{equation}
y(t)=1+\frac{1}{2} \mathbf{e}^{-4 t}-\frac{1}{2} \mathbf{e}^{-2 t}
\end{equation}

```python
def solution(t):
    y=1+0.5*math.exp(-4*t)-0.5*math.exp(-2*t)
    return y

```

```python
a_results=[]
time_list=np.arange(0,0.6,0.01)
for t in time_list:
    a_results.append(solution(t))

```

```python

```

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

def f(t):
    y=1+0.5*np.exp(-4*t)-0.5*np.exp(-2*t)
    return y

t2 = np.arange(0.0, 6.0, 0.02)

plt.figure(1)
plt.plot(times,results,'bo',t2, f(t2), 'k')
plt.show()

```

Blue dots are numerical approximations with Euler Method

```python

```


# Time Series Forecasting using the NOAA Weather Data of JFK Airport (New York)

This notebook relates to the NOAA Weather Dataset - JFK Airport (New York). The dataset contains 114,546 hourly observations of 12 local climatological variables (such as temperature and wind speed) collected at JFK airport. This dataset can be obtained for free from the IBM Developer [Data Asset Exchange](https://developer.ibm.com/exchanges/data/all/jfk-weather-data/).

In this notebook we explore approaches to predicting future temperatures by using the time-series dataset.

### Table of Contents:
* [1. Read the Cleaned Data](#cell1)
* [2. Explore Baseline Models](#cell2)
* [3. Train Statistical Time-series Analysis Models](#cell3)
* [Authors](#cell4)

#### Import required modules

Import and configure the required modules.

```python
!pip3 install statsmodels
!pip3 install sklearn
!pip3 install matplotlib
```

```python
import numpy as np
import pandas as pd
from sklearn.metrics import mean_squared_error
from statsmodels.tsa.statespace.sarimax import SARIMAX
from matplotlib import pyplot as plt
```

### 1. Read the Cleaned Data

We start by reading the cleaned dataset that was created in notebook `Part 1 - Data Cleaning`.

**Note:** if you haven't yet run this notebook, run it first; otherwise the cells below will not work.

```python
data = pd.read_csv('data/noaa-weather-data-jfk-airport/jfk_weather_cleaned.csv', parse_dates=['DATE'])
```

```python
# Set date index
data = data.set_index(pd.DatetimeIndex(data['DATE']))
data.drop(['DATE'], axis=1, inplace=True)
data.head()
```

For purposes of time-series modeling, we will restrict our analysis to a 2-year sample of the dataset to avoid overly long model-training times.

```python
sample = data['2016-01-01':'2018-01-01']
sample.info()
```

#### Create Training/Validation/Test Splits

Before we attempt any time-series analysis and prediction, we should split the dataset into training, validation and test sets. We use a portion of the data for training, and a portion of _future_ data for our validation and test sets.

If we instead trained a model on the full dataset, the model would learn to be very good at making predictions on that particular dataset, essentially just copying the answers it knows. However, when presented with data the model _has not seen_ , it would perform poorly since it has not learned how to generalize its answers.

By training on a portion of the dataset and testing the model's performance on another portion of the dataset (which data the model has not seen in training), we try to avoid our models "over-fitting" the dataset and make them better at predicting temperatures given unseen, future data. This process of splitting the dataset and evaluating a model's performance on the validation and test sets is commonly known as [cross-validation](https://en.wikipedia.org/wiki/Cross-validation_(statistics)).

By default here we use 80% of the data for the training set and 10% each for validation and test sets.

```python
def split_data(data, val_size=0.1, test_size=0.1):
    """
    Splits data to training, validation and testing parts
    """
    ntest = int(round(len(data) * (1 - test_size)))
    nval = int(round(len(data) * (1 - test_size - val_size)))

    df_train, df_val, df_test = data.iloc[:nval], data.iloc[nval:ntest], data.iloc[ntest:]

    return df_train, df_val, df_test

# Create data split
df_train, df_val, df_test = split_data(sample)

print('Total data size:      {} rows'.format(len(sample)))
print('Training set size:    {} rows'.format(len(df_train)))
print('Validation set size:  {} rows'.format(len(df_val)))
print('Test set size:        {} rows'.format(len(df_test)))
```

### 2. Explore Baseline Models

In this section, we'll create a few simple predictive models of temperature, using shifting and rolling averages. These will serve as a baseline against which we can compare more sophisticated models.

Using values at recent timesteps (such as the most recent timestep `t-1` and second-most recent timestep `t-2`) to predict the current value at time `t` is what's known as persistence modeling, or using the last observed value to predict the next following value. These preceding timesteps are often referred to in time-series analysis as `lags`. So, the value at time `t-1` is known as the `1st lag` and the value at time `t-2` is the `2nd lag`.

We can also create baselines based on rolling (or moving) averages. This is a time-series constructed by averaging each lagged value up to the selected lag. For example, a 6-period (or 6-lag) rolling avearge is the average of the previous 6 hourly lags `t-6` to `t-1`.

Our baseline models will be:
1. `1st lag` - i.e. values at `t-1`
2. `2nd lag` - i.e. values at `t-2`
3. `6-lag` rolling average
4. `12-lag` rolling average

```python
# define the column containing the data we wish to model - in this case Dry Bulb Temperature (F)
Y_COL = 'dry_bulb_temp_f'

# Use shifting and rolling averages to predict Y_COL (t)
n_in = 2
n_out = 1
features = [Y_COL]
n_features = len(features)

# create the baseline on the entire sample dataset.
# we will evaluate the prediction error on the validation set
baseline = sample[[Y_COL]].loc[:]
baseline['{} (t-1)'.format(Y_COL)] = baseline[Y_COL].shift(1)
baseline['{} (t-2)'.format(Y_COL)] = baseline[Y_COL].shift(2)
baseline['{} (6hr rollavg)'.format(Y_COL)] = baseline[Y_COL].rolling('6H').mean()
baseline['{} (12hr rollavg)'.format(Y_COL)] = baseline[Y_COL].rolling('12H').mean()
baseline.dropna(inplace=True)
baseline.head(10)
```

Next, we will plot data from our validation dataset to get a sense for how well these baseline models predict the next hourly temperature. Note thatd we only use a few days of data in order to make the plot easier to view.

```python
# plot first 7 days of the validation set, 168 hours
start = df_val.index[0]
end = df_val.index[167]
sliced = baseline[start:end]
```

```python
# Plot baseline predictions sample
cols = ['dry_bulb_temp_f', 'dry_bulb_temp_f (t-1)', 'dry_bulb_temp_f (t-2)', 'dry_bulb_temp_f (6hr rollavg)', 'dry_bulb_temp_f (12hr rollavg)']
sliced[cols].plot()

plt.legend(['t', 't-1', 't-2', '6hr', '12hr'], loc=2, ncol=3)
plt.title('Baselines for First 7 Days of Validation Set')
plt.ylabel('Temperature (F)')
plt.tight_layout()
plt.rcParams['figure.dpi'] = 100
plt.show()
```

#### Evaluate baseline models

As you can perhaps see from the graph above, the _lagged_ baselines appear to do a better job of forecasting temperatures than the _rolling average_ baselines.

In order to evaluate our baseline models more precisely, we need to answer the question _"how well do our models predict future temperature?"_. In regression problems involving prediction of a numerical value, we often use a measure of the difference between our predicted value and the actual value. This is referred to as an error measure or error metric. A common measure is the Mean Squared Error (MSE):

\begin{equation}
MSE = \frac{1}{n} \sum_{i=1}^{n}{(y_i - \hat y_i)^{2}}
\end{equation}

This is the average of the squared differences between predicted values $ \hat y $ and actual values $ y $.

Because the MSE is in "units squared" it can be difficult to interpet, hence the Root Mean Squared Error (RMSE) is often used:

\begin{equation}
RMSE = \sqrt {MSE}
\end{equation}

This is the square root of the MSE, and is in the same units as the values $ y $. We can compare the RMSE (and MSE) values for different models and say that the model that has the lower MSE is better at predicting temperatures, all things equal. Note that MSE and RMSE will grow large quickly if the differences between predicted and actual values are large. This may or may not be a desired quality of your error measure. In this case, it is probably a good thing, since a model that makes large mistakes in temperature prediction will be much less useful than one which makes small mistakes.

Next, we calculate the RMSE measure for each of our baseline models, on the full validation set.

```python
# Calculating baseline RMSE
start_val = df_val.index[0]
end_val = df_val.index[-1]
baseline_val = baseline[start_val:end_val]

baseline_y = baseline_val[Y_COL]
baseline_t1 = baseline_val['dry_bulb_temp_f (t-1)']
baseline_t2 = baseline_val['dry_bulb_temp_f (t-2)']
baseline_avg6 = baseline_val['dry_bulb_temp_f (6hr rollavg)']
baseline_avg12 = baseline_val['dry_bulb_temp_f (12hr rollavg)']

rmse_t1 = round(np.sqrt(mean_squared_error(baseline_y, baseline_t1)), 2)
rmse_t2 = round(np.sqrt(mean_squared_error(baseline_y, baseline_t2)), 2)
rmse_avg6 = round(np.sqrt(mean_squared_error(baseline_y, baseline_avg6)), 2)
rmse_avg12 = round(np.sqrt(mean_squared_error(baseline_y, baseline_avg12)), 2)

print('Baseline t-1 RMSE:            {0:.3f}'.format(rmse_t1))
print('Baseline t-2 RMSE:            {0:.3f}'.format(rmse_t2))
print('Baseline 6hr rollavg RMSE:    {0:.3f}'.format(rmse_avg6))
print('Baseline 12hr rollavg RMSE:   {0:.3f}'.format(rmse_avg12))
```

The RMSE results confirm what we saw in the graph above. It is clear that the _rolling average_ baselines perform poorly. In fact, the `t-2` lagged baseline is also not very good. It appears that the best baseline model is to simply use the current hour's temperature to predict the next hour's temperature!

Can we do better than this simple baseline using more sophisticated models?

### 2. Train Statistical Time-series Analysis Models

In the previous section, we saw that a simple `lag-1` baseline model performed reasonably well at forecasting temperature for the next hourly time step. This is perhaps not too surprising, given what we know about hourly temperatures. Generally, the temperature in a given hour will be quite closely related to the temperature in the previous hour. This phenomenon is very common in time-series analysis and is known as [autocorrelation](https://en.wikipedia.org/wiki/Autocorrelation) - that is, the time series is `correlated` with previous values of itself. More precisely, the values at time `t` are correlated with lagged values (which could be `t-1`, `t-2` and so on).

Another thing we saw previously is the concept of _moving averages_. In this case the moving-average baseline was not that good at prediction. However it is common in many time-series for a moving average to capture some of the underlying structure and be useful for prediction.

In order to make our model better at predicting temperature, ideally we would want to take these aspects into account. Fortunately, the statistical community has a long history of analyzing time series and has created many different forecasting models.

Here, we will explore one called SARIMAX - the **S**easonal **A**uto**R**egressive **I**ntegrated **M**oving **A**verage with e**X**ogenous regressors model.

This sounds like a very complex name, but if we look at the components of the name, we see that it includes `autocorrelation` (this is what auto regressive means) and `moving averages`, which are the components mentioned above.

The SARIMAX model also allows including a _seasonal_ model component as well as handling *exogenous* variables, which are external to the time-series value itself. For example, for temperature prediction we may wish to take into account not just previous temperature values, but perhaps other weather features which may have an effect on temperature (such as humidity, rainfall, wind, and so on).

For the purposes of this notebook, we will not explore modeling of seasonal components or exogenous variables.

If we drop the "S" and "X" from the model, we are left with an [ARIMA model](https://en.wikipedia.org/wiki/Autoregressive_integrated_moving_average) (Auto-regressive Integrated Moving Average). This is a very commonly used model for time-series analysis and we will use it in this notebook by only specifying the relevant model components of the full SARIMAX model.

#### 2.1 Replicating a baseline model

As a starting point, we will see how we can use SARIMAX to create a simple model that in fact replicates one of the baselines we created previously. Auto-regression, as we have seen, means using values from preceding time periods to predict the current value. Recall that one of our baseline models was the `1st lag` or `t-1` model. In time-series analysis this is referred to as an **AR(1)** model, meaning an **A**uto-**R**egressive model for `lag 1`.

Technically, the AR(1) model is not exactly the same as our baseline model. A statistical time series model like SARIMAX learns a set of `weights` to apply to each component of the model. These weights are set so as to best fit the dataset. We can think of our baseline as setting the `weight` for the `t-1` lag to be exactly `1`. In practice, our time-series model will not have a weight of exactly `1` (though it will likely be very close to that), hence the predictions will be slightly different.

Now, lets fit our model to the dataset. First, we will set up the model inputs by taking the temperature column of our dataframe. We do this for training and validation sets.

```python
X_train = df_train[Y_COL]
X_val = df_val[Y_COL]
X_both = np.hstack((X_train, X_val))
```

Here we created a variable called `X_both` to cover both the training and validation data. This is required later when we forecast values for our SARIMAX model, in order to give the model access to all the datapoints for which it must create forecasts. Note that the forecasts themselves will only be based on the _model weights_ learned from the training data (this is important for over-fitting as we have seen above)!

The SARIMAX model takes an argument called `order`: this specifies the components of the model and itself has 3 parts: `(p, d, q)`. `p` denotes the lags for the AR model and `q` denotes the lags for the MA model. We will not cover the `d` parameter here. Taken together this specifies the parameters of the [ARIMA](https://en.wikipedia.org/wiki/Autoregressive_integrated_moving_average) model portion of SARIMAX.

To create an AR(1) model, we set the `order` to be `(1, 0, 0)`. This sets up the AR model to be a `lag 1` model. Then, we fit our model on the training data and inspect a summary of the trained model.

```python
order = (1, 0, 0)
model_ar1 = SARIMAX(X_train, order=order)
results_ar1 = model_ar1.fit()
results_ar1.summary()
```

There's quite a lot of information printed out in the model summary above. Much of it is related to the statistical properties of our model.

The most important thing for now is to look at the second table, where we can see a `coef` value of `0.9996` for the weight `ar.L1`. This tells us the model has set a weight for the `1st lag` component of the AR model to be `0.9996`. This is almost `1` and hence we should expect the prediction results to indeed be close to our `t-1` baseline.

Let's create our model forecast on the validation dataset. We will then plot a few data points like we did with our baseline models (using 7 days of validation data) and compute the RMSE value based on the full validation set.

```python
full_data_ar1 = SARIMAX(X_both, order=order)
model_forecast_ar1 = full_data_ar1.filter(results_ar1.params)
```

```python
start = len(X_train)
end = len(X_both)
forecast_ar1 = model_forecast_ar1.predict(start=start, end=end - 1, dynamic=False)

# plot actual vs predicted values for the same 7-day window for easier viewing
plt.plot(sliced[Y_COL].values)
plt.plot(forecast_ar1[:168], color='r', linestyle='--')
plt.legend(['t', 'AR(1)'], loc=2)
plt.title('AR(1) Model Predictions for First 7 Days of Validation Set')
plt.ylabel('Temperature (F)')
plt.tight_layout()
plt.show()
```

We can see that the plot looks almost identical to the plot above, for the `t` and `t-1 baseline` values.

Next, we compute the RMSE values.

```python
# compute print RMSE values
rmse_ar1 = np.sqrt(mean_squared_error(baseline_val[Y_COL], forecast_ar1))
print('AR(1) RMSE:                   {0:.3f}'.format(rmse_ar1))
print('Baseline t-1 RMSE:            {0:.3f}'.format(rmse_t1))
```

We can see that the RMSE values for the validation set also almost identical.

#### 2.2 Create a more complex model

One of our baseline models was a `lag 2` model, i.e. `t-2`. We saw that it performed a lot worse than the `t-1` baseline. Intuitively, this makes sense, since we are throwing away a lot of information about the most recent lag `t-1`. However, the `t-2` lag still provides some useful information. In fact, for temperature prediction it's likely that the last few hours can provide some value.

Fortunately, our ARIMA model framework provides an easy way to incorporate further lag information. We can construct a model that includes _both_ the `t-1` and `t-2` lags. This is an **AR(2)** model (meaning an auto-regressive model up to lag `2`). We can specify this with the model order parameter `p=2`.

```python
order = (2, 0, 0)
model_ar2 = SARIMAX(X_train, order=order)
results_ar2 = model_ar2.fit()
results_ar2.summary()
```

This time, the results table indicates a weight for variable `ar.L1` _and_ `ar.L2`. Note the values are now quite different from `1` (or `0.5` say, for a simple equally-weighted model). Next, we compute the RMSE on the validation set.

```python
full_data_ar2 = SARIMAX(X_both, order=order)
model_forecast_ar2 = full_data_ar2.filter(results_ar2.params)

start = len(X_train)
end = len(X_both)
forecast_ar2 = model_forecast_ar2.predict(start=start, end=end - 1, dynamic=False)

# compute print RMSE values
rmse_ar2 = np.sqrt(mean_squared_error(baseline_val[Y_COL], forecast_ar2))
print('AR(2) RMSE:                   {0:.3f}'.format(rmse_ar2))
print('AR(1) RMSE:                   {0:.3f}'.format(rmse_ar1))
print('Baseline t-1 RMSE:            {0:.3f}'.format(rmse_t1))
```

We've improved the RMSE value by including information from the first two lags.

In fact, you will see that if you continue to increase the `p` parameter value, the RMSE will continue to decrease, indicating that a few recent lags provide useful information to our model.

#### 2.3 Incorporate moving averages

Finally, what if we also include moving average information in our model? The ARIMA framework makes this easy to do, by setting the order parameter `q`. A value of `q=1` specifies a **MA(1)** model (including the first lag `t-1`), while `q=6` would include all the lags from `t-1` to `t-6`.

Note that the moving average model component is a little different from the simple moving or rolling averages computed in the baseline models. The [definition of the MA model](https://en.wikipedia.org/wiki/Moving-average_model) is rather technical, but conceptually you can think of it as using a form of weighted moving average (compared to our baseline which would be a simple, unweighted average).

Let's add an MA(1) component to our AR(2) model.

```python
order = (2, 0, 1)
model_ar2ma1 = SARIMAX(X_train, order=order)
results_ar2ma1 = model_ar2ma1.fit()
results_ar2ma1.summary()
```

We see the results table shows an additional weight value for `ma.L1`, our MA(1) component. Next, we compare the RMSE to the other models and finally plot all the model forecasts together - _note_ we use a much smaller 48-hour window to make the plot readable for illustrative purposes.

```python
full_data_ar2ma1 = SARIMAX(X_both, order=order)
model_forecast_ar2ma1 = full_data_ar2ma1.filter(results_ar2ma1.params)

start = len(X_train)
end = len(X_both)
forecast_ar2ma1 = model_forecast_ar2ma1.predict(start=start, end=end - 1, dynamic=False)

# compute print RMSE values
rmse_ar2ma1 = np.sqrt(mean_squared_error(baseline_val[Y_COL], forecast_ar2ma1))
print('AR(2) MA(1) RMSE:             {0:.3f}'.format(rmse_ar2ma1))
print('AR(2) RMSE:                   {0:.3f}'.format(rmse_ar2))
print('AR(1) RMSE:                   {0:.3f}'.format(rmse_ar1))
print('Baseline t-1 RMSE:            {0:.3f}'.format(rmse_t1))
```

```python
# plot actual vs predicted values for a smaller 2-day window for easier viewing
hrs = 48
plt.plot(sliced[Y_COL][:hrs].values)
plt.plot(forecast_ar1[:hrs], color='r', linestyle='--')
plt.plot(forecast_ar2[:hrs], color='g', linestyle='--')
plt.plot(forecast_ar2ma1[:hrs], color='c', linestyle='--')
plt.legend(['t', 'AR(1)', 'AR(2)', 'AR(2) MA(1)'], loc=2, ncol=1)
plt.title('ARIMA Model Predictions for First 48 hours of Validation Set')
plt.ylabel('Temperature (F)')
plt.tight_layout()
plt.show()
```

We've again managed to reduce the RMSE value for our model, indicating that adding the MA(1) component has improved our forecast!

Congratulations! You've applied the basics of time-series analysis for forecasting hourly temperatures. See if you can further improve the RMSE values by exploring the different values for the model parameters `p`, `q` and even `d`!

### Authors

This notebook was created by the [Center for Open-Source Data & AI Technologies](http://codait.org).

Copyright © 2019 IBM. This notebook and its source code are released under the terms of the MIT License.

pipelines/hello_world_kubeflow_pipelines/Part 3 - Time Series Forecasting.ipynb


# PID Tuning

Let's work out Example 7.7 under the assumption that we don't actually have a model for the system. This code will simulate some system that we can measure the response for, but that is the only information we have about it.

We'll first need to code the system using the block diagram in Figure 7.25.

As always with python, we start by importing all of the packages we'll need.

```python
import numpy as np
import control
import matplotlib.pyplot as plt # plotting library
from sympy import symbols
from sympy.physics.control.lti import TransferFunction, Feedback, Series
from sympy.physics.control.control_plots import pole_zero_plot, step_response_plot, step_response_numerical_data
```

Let's use sympy to perform symbolic algebra on our block diagram to make sure we didn't make a mistake

```python
# define all of the symbols we're going to use
Kp, KI, KD, b, zeta, wn, s = symbols('K_p K_I K_D b zeta omega_n s')

# We'll get the closed-loop TF of Y/R first

# Define the TF of the Process (Gp in the notes)
num_Gp = 1
den_Gp = s*(s+b)*(s+2*zeta*wn)
Gp = TransferFunction(num_Gp,den_Gp,s)

# Define the TF of the Controller (Gc in the notes)
num_Gc = KD*s**2 + Kp*s + KI
den_Gc = s
Gc = TransferFunction(num_Gc,den_Gc,s)

# Get closed-loop TF Y/R when Td=0
TF_R_OL = Series(Gc,Gp).doit() # Open-loop TF
TF_R = Feedback(TF_R_OL, sign=-1).doit()

TF_R.simplify().expand()
```

$\displaystyle \frac{K_{D} s^{2} + K_{I} + K_{p} s}{K_{D} s^{2} + K_{I} + K_{p} s + 2 b \omega_{n} s^{2} \zeta + b s^{3} + 2 \omega_{n} s^{3} \zeta + s^{4}}$

```python
# Now get the closed-loop TF of Y/Td when R=0

TF_Td = Feedback(Gp,Gc,sign=-1).doit()

TF_Td.simplify().expand()
```

$\displaystyle \frac{s}{K_{D} s^{2} + K_{I} + K_{p} s + 2 b \omega_{n} s^{2} \zeta + b s^{3} + 2 \omega_{n} s^{3} \zeta + s^{4}}$

## Manual Model-free Tuning
Now we can act like we don't know anything about the system and instead observe the response as we change the gains.

The first step is to set Kd and KI to zero and increase Kp until the system is nearly unstable. We'll do this with the $\frac{Y(s)}{R(s)}$ TF.

Let's start with $K_p=100$.

```python
# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,0),
              (KD,0),
              (Kp,100)]) # We'll start with Kp=100

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

This is definitely not nearly unstable. Let's try $K_p=1000$.

```python
# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,0),
              (KD,0),
              (Kp,1000)])

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

This is unstable, which means $K_p$ is too high.

Let's do $K_p=885.5$. (This is from the book. I didn't just eye-ball it.)

```python
# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,0),
              (KD,0),
              (Kp,885.5)])

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

That seems about right.

The next step is to reduce $K_p$ until the amplitude of the second peak is $\frac{1}{4}$ of the first peak. The book suggests starting by making $K_p$ half, so $K_p=\frac{885.5}{2}=442.75$. Continue modifying $K_p$ until the desired value is reached. In this case, $K_p=370$.

```python
# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,0),
              (KD,0),
              (Kp,370)])

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

That seems like a nice response. What can we do to improve it?

We can add derivative gain, $K_D$. Remember that $K_D$ will act like a damper to reduce our settling time and overshoot. Let's use $K_D=60$.

```python
# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,0),
              (KD,60),
              (Kp,370)])

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

This is a pretty good response. Theoretically, we don't have to add integral gain, $K_I$, because we don't have any steady state error. But in reality, we'll almost always have some steady-state error. Sometimes its negligible, sometimes not. Let's add $K_I=100$ and see what happens.

```python
# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,100),
              (KD,60),
              (Kp,370)])

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

Looks like in this case $K_I$ has added a noticable increase in settling time. That is the trade-off for having a mechinism that will mitigate our steady-state error.

## Ziegler-Nichols Tuning

Let's compare manual tuning to the closed-loop Ziegler-Nichols method. We already know that the ultimate gain is $K_U=885.5$. The ultimate period was also $P_U=0.83$ seconds. From there we use the table to assign the gains as:

$K_p=0.6K_U$

$K_I=\frac{1.2K_U}{P_U}$

$K_D=\frac{0.6K_U P_U}{8}$

The step response with these gains looks like this.

```python
KU=885.5 # ultimate gain
PU=0.83 # ultimate period

prop_gain = 0.6*KU
int_gain = 1.2*KU/PU
deriv_gain = 0.6*KU*PU/8

# sub in values for the variables
TF = TF_R.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,int_gain),
              (KD,deriv_gain),
              (Kp,prop_gain)])

step_response_plot(TF,upper_limit=3,grid=False,color='r')
```

The gains from using Ziegler-Nichols cause the system to have much more overshoot than with manual tuning. So why not always use manual tuning? First, Ziegler-Nichols allowed us to tune the gains faster with less trial and error. Second, Ziegler-Nichols was designed to give systems decent disturbance rejection.

If we apply a step input in disturbance when $R(s)=0$:

```python
# sub in values for manual tuning
TF_Td_manual = TF_Td.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,100),
              (KD,60),
              (Kp,370)])

# sub in values for Ziegler-Nichols tuning
TF_Td_ZN = TF_Td.subs([
              (b,10),
              (zeta,0.707),
              (wn,4),
              (KI,int_gain),
              (KD,deriv_gain),
              (Kp,prop_gain)])

manual_response = step_response_numerical_data(TF_Td_manual,upper_limit=10,grid=False,color='r')
ZN_response = step_response_numerical_data(TF_Td_ZN,upper_limit=10,grid=False,color='r')

# plot the two responses
#############################################################
fig = plt.figure(figsize=(6,4))
ax = plt.gca()
plt.subplots_adjust(bottom=0.17, left=0.17, top=0.96, right=0.96)

# Change the axis units font
plt.setp(ax.get_ymajorticklabels(),fontsize=18)
plt.setp(ax.get_xmajorticklabels(),fontsize=18)

ax.spines['right'].set_color('none')
ax.spines['top'].set_color('none')

ax.xaxis.set_ticks_position('bottom')
ax.yaxis.set_ticks_position('left')

# Turn on the plot grid and set appropriate linestyle and color
ax.grid(True,linestyle=':', color='0.75')
ax.set_axisbelow(True)

# Define the X and Y axis labels
plt.xlabel('Time (s)', fontsize=22, weight='bold', labelpad=5)
plt.ylabel('Amplitude', fontsize=22, weight='bold', labelpad=10)

plt.plot(manual_response[0], manual_response[1], linewidth=2, linestyle='-', label=r'Manual Tuning')
plt.plot(ZN_response[0], ZN_response[1], linewidth=2, linestyle='-', label=r'ZN Tuning')
# uncomment below and set limits if needed
# plt.xlim(0,5)
# plt.ylim(-0.01,2.5)

# Create the legend, then fix the fontsize
leg = plt.legend(loc='upper right', ncol = 1, fancybox=True, )
ltext  = leg.get_texts()
plt.setp(ltext,fontsize=16)

# Adjust the page layout filling the page using the new tight_layout command
plt.tight_layout(pad=0.5)

# save the figure as a high-res pdf in the current folder
# plt.savefig('plot_filename.pdf')

# plt.show()
```

```python

```