# 4. Non-linear Classification

 * Nonlinear transformation on data, then just apply a linear classifier
 * We will use this *kernel trick* with our SVMs
 * In order to be able to do this, we will have to reformulate one of our primal SVM formulations into a dual formulation

## Background: Convexity

Gathered from multiple sources, such as Wikipedia, the ETH DM lecture slides, mmds.org, and Stanford lectures on convex optimization (EE 364A, available on YouTube).

### Positive semidefiniteness
 * Only defined for square matrices (obvious, see definition below)
 * $X \in \mathbb{R}^{n \times n}$ positive definite $\iff z^TXz \ge 0 \> \forall z$
 * semi = non-strict inequality
 * Analogous definitions for negative and for non-semi variants

### Affine and convex sets
 * An **affine set** always also contains the line through any two  points in itself.
     - Any two points $x_1, x_2$; $x = \theta x_1 + (1 - \theta)x_2, \theta \in \mathbb{R}$
     - example: solution set of linear equations $\{x \> | \> Ax = b \}$.
 * A **convex set** always contains the line segment between any two points in itself.
     - Any two points $x_1, x_2$; $x = \theta x_1 + (1 - \theta)x_2, \theta \in [0, 1]$
     - example: from geometry, a square-shape is convex, a donut isn't
     - Intuition: an affine set is a more general form of a convex set
     - *Any affine set is also a convex set.*

### Convex combination and convex hull
 * A **convex combination** of points $x_1,\dots,x_k$: any point $x$ of the form:
     - $ x = \theta_1x_1 + \theta_2x_2 + \dots + \theta_kx_k $
     - with $\theta_1 + \dots + \theta_k = 1, \> \theta_i \ge 0$
     - omitting the second constraint $\implies$ linear combination
 * A **convex hull of a set S** ($\operatorname{conv} S$)  is the set of all convex combination points in S.

### Hyperplanes and halfspaces
 * A **hyperplane** is a set of the form $\{x \> | \> a^Tx = b\}, (a \ne 0)$
     - $a$ is the normal, $b$ is the intercept (or bias)
     - all hyperplanes are affine (and convex)
 * A **halfspace** is a set of the form $\{x \> | \> a^x < b\}, (a \ne 0)$
     - same terminology as for hyperplanes
     - halfspaces are not vector spaces (not closed under scalar multiplication; we can easily "escape it")
     - all halfspaces are convex

### Euclidean balls and ellipsoids
 * Euclidean = using the $l_2$ (Euclidean) norm in their description
 * An **(Euclidean) ball** of center $x_c$ and radius $r$ is
     - $B(x_c, r) = \left\{ x \> | \> \| x - x_c \|_2 \le r \right\} = \left\{x_c + ru \> | \> \|u\|_2 \le 1 \right\}$
 * An **ellipsoid** is a set of form:
     - $\left\{x\>|\> (x - x_c)^{T} P^{-1} (x - x_c) \le 1 \right\}$, with $P \in \mathbf{S}^n_{++}$ ($P$ symmetric, positive definite matrix)
     - generalization of the Euclidean ball
 * Both are convex sets

### Norms
 * Any function $\| \cdot \|$ which satisfies
     - positive; $\|x\| = 0 \iff x = 0$
     - $\|tx\| = \lvert t \rvert \|x\| \> \forall t \in \mathbb{R}$
     - $\|x + y \| \le \|x\| + \|y\|$
 * Can define a *norm ball* with any norm and radius. The Euclidean ball from the previous section was a particular example.
     - in $\mathbb{R}^2$, $\| \cdot \|_2$ is a diamond shape centered around the origin; $\| \cdot \|_\infty$ is a square.

### Polyhedra
 * Solution sets of finitely many linear equalities and inequalities
     - $Ax \preccurlyeq b$ (component-wise less than, since we're shoving a bunch of inequalities in a big matrix-vector multiplication followed by a vector-vector comparison) and $Cx = d$
 * Convex
 * Polyhedron is intersection of finite number of halfspaces and hyperplanes
 * Halfspaces and hyperplanes are special cases of polyhedra
 * Equalities turn our resulting polyhedron into a "slice" in that dimension

### Preserving convexity
Practical methods for establishing convexity of a set $C$

 * Apply definition
     $x_1, x_2 \in C, \> 0 \le \theta \le 1 \implies \theta x_1 + (1 - \theta)x_2 \in C$

 * Show that $C$ is obtained from simple convex sets (hyperplanes, halfspaces, norm balls, etc.) by operations that preserve convexity
    * intersection (any number); the intersection of any number of convex sets is convex
    * affine functions
    * perspective functions
    * linear-fractional functions

 * "Matlab" approach
    * Pick some random points from the space
    * Test points by brute force (or maybe just keep trying with $\theta = 0.5$)

### Affine functions
 * Suppose $f : \mathbb{R}^n \rightarrow \mathbb{R}^m$ is *affine* ($\> f(x) = Ax + b, A \in \mathbb{R}^{m \times n}, b \in \mathbb{R}^m$)
 * $f$ turns convex sets into convex sets
 * $f^{-1}$ does the same
 * Examples: scaling, translation, projection

### Perspective and linear-fractional functions
 * A **perspective function** $P : \mathbb{R}^{n+1} \rightarrow \mathbb{R}^{n}$:
     * $P(x, t) = x / t$ (divide first n - 1 by last element and discard it)
     * Perspective functions and their inverses conserve convexity
 * A **linear-fractional function**
     * A generalization of the perspective function.
     * Maintains convexity

### Generalized inequalities and cones
 * A simple ray from the origin is not a proper cone.
 * The nonnegative orthant is a proper cone.
 * Can define point inequalities in proper cones. However, they don't define a general ordering.
 * Minimum vs. minimal has to deal with other, non-comparable points (which exist when we're not dealing with a general ordering)
 * A dual cone of a cone $K$:
     - $K^* = \left\{y \> \lvert \> y^Tx \ge 0 \> \forall \> x \in K \right\}$
     - Any vector in the dual cone makes an angle $\le 90$ degrees to any vector in the original cone
     - Some cones are self-dual (e.g. the positive orthant)
     - $(K^*)^* = K$ if $K$ is proper
 * Optimal production frontier problem
     - the optimal frontier vectors are vecotrs minimal w.r.t. $\mathbb{R}_{+}^{n}$

### Supporting hyperplane theorem
 * A **supporting hyperplane** to set $C$ at boundary point $x_0$: $\left\{x \> | \> a^T x = a^T x_0 \right\}$, where $a \ne 0$ and $a^Tx \le a^Tx_0 \> \forall x \in C$
 * Goes through $x_0$ and all of the set is on one side of the hyperplane

## Background: Convex functions

A function is **convex** if its domain ($\operatorname{\mathbf{dom}} \> f$) is a convex set and

\begin{equation}
    f(\theta x + (1 - \theta) y) \le \theta f(x) + (1 - \theta)f(y), \> \forall x, y \in \operatorname{\mathbf{dom}}{f}, 0 \le \theta \le 1 \end{equation}

Roughly speaking, upward curvature.
Strict convexity defined trivially.
$f$ is concave is $-f$ is convex.

Examples:
 * affine (linear + constant): $ax + b$ on $\mathbb{R}$ (the definition of convexity actually becomes an equality)
 * exponentials, powers, powers of absolute value
 * negative entropy $x\log{x}$ on $\mathbb{R}_{++}$
 * All norms are convex
 * All affine functions are both convex and concave

Trivial to establish for functions on $\mathbb{R}$.

Matrix function examples ($\mathbb{R}^{m \times n}$):
 * Affine function

\begin{equation}
    f(X) = \operatorname{tr}{A^TX} + b = \sum\limits_{i=1}^m \sum\limits_{j=1}^nA_{i,j}X_{i,j} + b \> \text{(the trace of the product is just the inner product of two matrices)}
\end{equation}

 * Spectral(maximum singular value) norm (sqrt of max eigenvalue):

\begin{equation}
    f(X) = \|X\|_2 = \sigma_{max}(X) = \left(\lambda_{max}(X^TX)\right)^{\frac{1}{2}} \> \text{(non-trival to compute)}
\end{equation}

TODO: Stanford Convex Optimization I, Lecture 3, 22:10

## Linear program (LP)
 * Everything is affine (objective, (in)equality constraints).
 * Feasible set is *polyhedron*.
 * Objective is affine, so a hyperplane in practice.
 * Small LP $\implies$ astronomical number of vertices in feasible set.
 * Examples:
     - diet problem (solution: quantities $x_1,\dots,x_n$ of $n$ foods, each with costs and nutrient compositions; need to keep nutrients above certain thresholds while minimizing the cost of the food)
     - Cebyshev center of polyhedron

## Duality

A way to handle the constraints by incorporating them into the objective.
Irritation vs. value for a function. (See previous lecture)

### Lagrangian

Turns a constrained optimization problem like the following:

\begin{equation}
\begin{aligned}
\text{minimize} \quad   & f_0(x) \\
\text{subject to} \quad & f_i(x) \le 0, \quad i = 1, \dots, m \\
\text{ } & h_i(x) = 0, \quad i = 1, \dots, p
\end{aligned}
\end{equation}

Into a single objective $\mathcal{L}$ (or, simply $L$, if you don't want to type `\mathcal` all the time):

\begin{equation}
    \mathcal{L}(x, \lambda, v) = f_0(x) + \sum_{i=1}^{m}\lambda_i f_i(x) + \sum_{i=1}^{p} v_i h_i(x)
\end{equation}

$\lambda_i$ and $v_i$ are the **Lagrange multipliers**.
The vectors $\lambda$ and $v$ are called the *dual variables* or *Lagrange multiplier vectors*.

The **Lagrange dual function** $g(\lambda, v)$ is the minimum value of the Lagrangian over x (in the original data domain $\mathcal{D}$) for any $\lambda$ and $v$. Namely:

\begin{equation}
    g(\lambda, v) = \inf_{x \in \mathcal{D}} \> \mathcal{L}(x, \lambda, v)
\end{equation}

This function gives us lower bounds on the optimal value $p^*$ of the original problem (i.e. our solution can't ever be better than $g$).

### The Lagrange dual problem

The optimal solution for the problem is guaranteed to be larger than $g$ (lower bound property). *So what?*

Well, we can now solve the problem "from the other side" by maximizing the dual function, instead of minimizing the original constrained objective function.

\begin{equation}\begin{aligned}
\text{maximize} & \quad g(\lambda, v) \\
\text{subject to} & \quad \lambda \succcurlyeq 0
\end{aligned}\end{equation}

### A simple example: Least-squares solution of linear equations
SVMs are a bit complicated. Let's look at deriving the *Lagrange dual function* for a simpler problem first.

Primal statement:

\begin{equation}
\begin{aligned}
    \text{minimize}  \quad  & x^Tx \\
    \text{subject to} \quad & Ax = b
\end{aligned}
\end{equation}

This is a textbook example of a (convex) optimization problem.
$A \in R^{p \times n}$, which means there are $p$ equality constraints.
Apply Lagrange operator and we get the following *Lagrangian*, according to the definition:

\begin{equation}
    \mathcal{L}(x, v) = x^Tx + v^T (A x - b), \> \mathcal{L} : \mathbb{R}^{p \times n} \rightarrow \mathbb{R}
\end{equation}

This yields the *dual function* $g(v) = \inf_x \mathcal{L}(x, v)$. Its actual value is simple to figure out, since the Lagrangian is a convex quadratic function (like $x^2 + ax + c$, but with vectors and different constants).

\begin{equation}
    \nabla_x \mathcal{L}(x, v) = 2 x + A^Tv = 0 \implies x = -\frac{1}{2} A^Tv
\end{equation}

We can now formulate our dual function based on just $A$, $b$, and $v$:

\begin{equation}
\begin{split}
    g(v) & = \inf_x \mathcal{L}(x, v) \\
    & = \mathcal{L}(-\frac{1}{2}A^T v, v) \\
    & = \left(-\frac{1}{2} A^T v \right)^T \left(-\frac{1}{2} A^T v \right) + v^T \left( -\frac{1}{2} A A^T v - b \right) \\
    & = -\frac{1}{4} v^T A A^T v - b^T v
\end{split}
\end{equation}

## Duality applied to SVMs

We can apply the aforementioned Lagrange operator to the primal SVM formulation in order to automagically include its constraints (the slack variable inequalities) in the objective function:

\begin{equation}
    \max_\alpha \sum_{i=1}^{n} - \frac{1}{2}\sum_{i,j}\alpha_{i} \alpha_{j} y_i y_j x_i^{T} x_j \quad \text{s. t.} \> 0 \le \alpha_{i} \le C
\end{equation}

This is equivalent to an optimal model computed by minimizing the primal formulation expressed by $w^{*} = \sum_{i=1}^{n} \alpha_{i}^{*} y_i x_i$

The data points with $\alpha_i > 0$ are the **support vectors**
 - This means that our model only depends on the points $i$ which "misbehave", since otherwise the alpha would be 0. (dubious, do more research!)
 - Question: including points which are very "bad" and e.g. in the middle of the opposite class? Or is this completely different from how we see slack variables?

How do we predict with this formulation?

\begin{equation}
y = \operatorname{sign} \left( \sum_{i=1}^{n} \alpha_i y_i k(x_i, x) \right)
\end{equation}

This implies many things!
 - First off, no more "instant" classification as a result of taking the sign of an inner product (as was the case in the primal case).
 - We have a sum, so we need to do $\mathcal{O}(n)$ computations for every new point we want to classify.
 - Stop right there criminal scum!
 - Most $\alpha_i$s will be 0. So we only compute the sum from applying the kernel function to the new point and every support vector. And there are *way fewer* support vectors than total data points. Because otherwise it would mean that our data is very difficult to separate anyway, so a SVM wouldn't be a feasible approach anyway.
 - This may still prove to be to slow $\implies$ use "inverse kernel trick" (see below). This also doesn't work unless we train an SVM in a conventional way. So it doesn't work with (P)SGD, which is necessary when working with very large datasets.

## Kernel functions
 * Kernels are cool. So what can we use as a kernel?
 * A **kernel** is a function $k : X \times X \rightarrow \mathbb{R}$ which satisfies
     - *symmetry*: $\forall x, x' \in X \> k(x, x') = k(x', x)$ (otherwise our kernelized SVM would be sensitive to the order of our training data points; we're not doing OCP; this would be bad!)
     - *positive semi-definiteness* of the kernel (Gram) matrix
 * Examples include:
     - linear
     - polynomial
     - gaussian (unnormalized)
     - data-type-specific kernels

## The "Inverse Kernel Trick"
 * Explicitly generate low-dim nonlinear features, then apply ordinary linear method.
 * $x \in \mathbb{R}^d \rightarrow \phi(x) \in \mathbb{R}^{\text{D}} \rightarrow z(x) \in \mathbb{R}^m$
 * with $D \gg d$ and $m \ll D$
 * Note: this happens via feature generation. This is the key! We're operating at a "row level" in the data, and still using an ordinary (fast) linear method at the end (which doesn't depend on e.g. the number of support vectors).
 * A **shift invariant kernel** means $k(x, y) = k(x - y)$ (i.e. x and y only appear as a subtraction; $k$ is a function only of the difference between the arguments)
     - these kernels have a fourier transform, so we can generate random Fourier features and enrich our data points
 * A **radial basis kernel** is a *stronger shift invariant kernel*, which only depends on the magnitude of the difference between the two arguments, i.e. $k(x, x') = k(\|x, x'\|)$.

## Kernels and online learning

TODO

## Miscellaneous
 * Pareto optimal = not stupid (Prof. Stephen Boyd, Stanford EE dept.)


# Kozeny-Carman equation

\begin{equation}
    K = \dfrac{d_p^2}{180}\dfrac{\theta^3}{(1-\theta)^2} \dfrac{\rho g }{\mu}
\end{equation}

```python
%reset -f
```

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import root

#Globals
rho = 1000.  #kg/m3
g   = 9.81   #cm/s2
mu  = 0.001  #Ns/m2
dp  = 4.4E-4 #m

def KozenyCarman(theta):
    return dp**2 * theta**3 * rho * g / (180 * (1-theta)**2 * mu)

def Chen2010(theta):
    perm = 810.0*(theta)**3.1   # Original equation
    perm = perm * 9.869233E-13         # Darcy >> m2
    Kcon = perm * rho * g / mu  # Permeability >> Hydraulic cond.
    return Kcon

def Chen2009(theta):
    perm = 1600.0*(theta)**3.7   # Original equation
    perm = perm * 9.869233E-13         # Darcy >> m2
    Kcon = perm * rho * g / mu  # Permeability >> Hydraulic cond.
    return Kcon

def findTheta(K_expected=1.0E-8):

    def minimizer(theta):
        K_init = KozenyCarman(theta)
        return (K_init - K_expected)**2

    solution = root(minimizer,0.1)
    print(solution.message + f" >> Porosity = {solution.x}")
    return solution.x
```

```python

```

```python
rc = plt.rcParams

rc["font.family"] = "ubuntu"
rc["axes.labelsize"] = 12
rc["axes.labelweight"] = 'bold'
rc["axes.labelcolor"] = 'k'
rc["axes.labelpad"] = 10
rc["legend.fontsize"] = 12
rc["axes.spines.right"] = False
rc["axes.spines.top"] = False
```

```python
def BiofilmModel(theta,thetaf):
    theta0 = np.max(theta)
    nr = theta/theta0
    nr0 = thetaf/theta0
    k0 = KozenyCarman(theta)
    kf = KozenyCarman(thetaf)
    kmin = kf/k0
    nn = (nr-nr0)/(1-nr0)
    k = (1+(1+kmin)) * (np.power(nn,1.8) + kmin)
    return k*k0

def ColloniesModel(theta,thetaf):
    theta0 = np.max(theta)
    a = -1.8
    nr = theta/theta0
    nr0 = thetaf/theta0
    nn = (nr-nr0)/(1-nr0)

    k = (a * np.power(nn,3.0)) + ((1-a) * np.power(nn,2.0))
    k0 = KozenyCarman(theta)

    return k*k0
```

```python
def KnappCivan1988(theta):
    theta0 = np.max(theta)
    nr = theta/theta0
    k = np.power(nr,3.0)
    k0 = KozenyCarman(theta)
    return k*k0
```

```python
p = 0.35
print("Kozeny-Carman:  {:10.3E}".format(KozenyCarman(p)))
print("Chen2009:       {:10.3E}".format(Chen2009(p)))
print("Chen2010:       {:10.3E}".format(Chen2010(p)))
print("Biofilm:        {:10.3E}".format(BiofilmModel(p,0.05)))
print("Collonies:      {:10.3E}".format(ColloniesModel(p,0.05)))
```

    Kozeny-Carman:   1.071E-03
    Chen2009:        3.185E-04
    Chen2010:        3.027E-04
    Biofilm:         2.146E-03
    Collonies:       1.071E-03

```python
por0 = 0.04423837
porosity = np.linspace(por0,0.45,100)
hydrCond = KozenyCarman(porosity)
```

```python
fig,axs = plt.subplots(1,2,figsize=(10,4),sharey=True,sharex=True,gridspec_kw={'wspace':0.02});
ax=axs[0]
ax.plot(porosity,KozenyCarman(porosity),lw=2,c="blue",label='Kozeny-Carman')
ax.plot(porosity,Chen2010(porosity),lw=2,c="red",label="Chen2010")
ax.plot(porosity,Chen2009(porosity),lw=2,c="green",label="Chen2009")
ax.plot(porosity,KnappCivan1988(porosity),lw=2,c="orange",label="KnappCivan2988")
ax.set_ylabel("Hydraulic Conductivity \n$K$ [m/s]")

ax=axs[1]
ax.plot(porosity,KozenyCarman(porosity),lw=2,c="blue",label='Kozeny-Carman')
ax.plot(porosity,BiofilmModel(porosity,por0),lw=2,c="green",label="Biofilm Model")
ax.plot(porosity,ColloniesModel(porosity,por0),lw=2,c="purple",label="Colonies model")

for ax in axs:
    ax.set(yscale='log')#,xscale='log')
    ax.set_xlabel("Porosity $\\theta$ ")
    ax.axhline(y=1.0E-6,lw=1,ls='dotted')
    ax.set(ylim=[1.0E-7,1.0E-2])
    ax.legend()
plt.show()
```

```python
theta2 = findTheta(1.0E-6)
```

    The solution converged. >> Porosity = [0.04423837]

```python
print("{:.4E} m/s".format(KozenyCarman(0.35)))
```

    1.0707E-03 m/s

```python
from jupypft import attachmentRateCFT
```

```python
katt,_ = attachmentRateCFT.attachmentRate(dp=1.0E-7,dc=4.4E-4,
    q=0.35E-3,
    theta=0.35,
    visco=0.001,
    rho_f=1000.,
    rho_p=1050.0,
    A=1.0E-20,
    T=298.0,
    alpha=0.0043273861959162,
    debug=True)
```

    Diffusion coeff:  4.3654E-12
    Darcy velocity:   3.5000E-04
    Pore-water vel:   1.0000E-03
    ---
    Happel parameter: 5.2527E+01
    NR number:        2.2727E-04
    NPe number:       3.5277E+04
    NvW number:       2.4305E+00
    NGr number:       3.1211E-06
    ---
    etaD collector:   1.0409E-02
    etaI collector:   1.9641E-05
    etaG collector:   2.8626E-07
    eta0 collector:   1.0429E-02
    ---
    Attach rate   :   1.0000E-04

```python
"{:.6E}".format(0.0043273861959162)
```

    '4.327386E-03'

```python
1.0E-4/katt
```

    1.0000000000000002

```python

```


# Neural Networks as Dynamical Systems

A neuronal model is made up of an input vector $\overrightarrow{X}=(x_1,x_2,\ldots, x_n)^T$, A vector of synaptic weights, $W_k=w_{kj}$, $j=1,2,\ldots,n$ a bias $b_k$ and an output $y_k$.

The neuron itself is a nonlinear transfer function $\phi$.

\begin{align}
v_k &= W_k X + b_k\\
y_k &= \phi(v_k).
\end{align}

The function $\phi$, also known as the activation function, typically range from $-1$ to $+1$ and can take many forms such as the Heaviside function, piece-wise linear function, a sigmoid function, etc.

```python

```

# Training
training a neural network corresponds to the act of adjusting the parameters described above to minimize the error of the network with respect to a target vector.

## Generalized Delta rule
for a linear activation function, the weights are adjusted according to:

$$w_{kj}(n+1)=w_{kj}(n)-\eta g_{kj},$$

where n is the number of iterations, $g_{kj}=-(t_k-y_k)$, and $\eta$ is a small positive constant called the learning rate.

For a nonlinear activation function, the generalized delta rule is:

$$w_{kj}(n+1)=w_{kj}(n)-\eta g_{kj},$$

where

$$g_{kj}=(y_k-t_k)\frac{\partial \phi}{\partial v_k}x_j$$

```python
np.c_[np.ones(rows), X].shape
```

    (506, 5)

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

data = np.loadtxt('housing.txt')
rows, columns = data.shape
columns = 4  # Using 4 columns from data in this case

X = data[:, [5, 8, 12]]
t = data[:, 13]
ws1, ws2, ws3, ws4 = [], [], [], []
k = 0

# Scale the data to zero mean and unit variance
xmean = X.mean(axis=0)
xstd = X.std(axis=0)
ones = np.ones((1,rows))
X = (X - xmean * ones.T) / (xstd * ones.T)
X = np.c_[np.ones(rows), X]

tmean = (max(t) + min(t)) / 2
tstd = (max(t) - min(t)) / 2
t = (t - tmean) / tstd

w = 0.1 * np.random.random(columns)
y1 = np.tanh(X.dot(w))
e1 = t - y1
mse = np.var(e1)

num_epochs = 20  # number of iterations
eta = 0.001 # Learning rate
k = 1
erros = [e1.mean()]
for m in range(num_epochs):
    for n in range(rows):
        yk = np.tanh(X[n, :].dot(w))
        err = yk - t[n]
        g = X[n, :].T * ((1 - yk**2) * err)
        w = w - eta*g
        k += 1
        ws1.append([k, np.array(w[0]).tolist()])
        ws2.append([k, np.array(w[1]).tolist()])
        ws3.append([k, np.array(w[2]).tolist()])
        ws4.append([k, np.array(w[3]).tolist()])
#         print(err.mean())
        erros.append(err.mean())
#         break
# print(erros)
ws1 = np.array(ws1)
ws2 = np.array(ws2)
ws3 = np.array(ws3)
ws4 = np.array(ws4)

plt.plot(ws1[:, 0], ws1[:, 1], 'k.', markersize=0.1, label='ws1')
plt.plot(ws2[:, 0], ws2[:, 1], 'g.', markersize=0.1, label='ws2')
plt.plot(ws3[:, 0], ws3[:, 1], 'b.', markersize=0.1, label='ws3')
plt.plot(ws4[:, 0], ws4[:, 1], 'r.', markersize=0.1, label='ws4')
# plt.plot(erros, label='erro')
plt.xlabel('Number of iterations', fontsize=15)
plt.ylabel('Weights', fontsize=15)
plt.tick_params(labelsize=15)
plt.grid()
plt.legend()
plt.show()
```

## Backpropagation
Backpropagation is the most common training algorithm in use. It's required to train neurons in a *hidden* layer.

```python
%%html
<div style='position:relative; padding-bottom:calc(56.25% + 44px)'></div><p> <a href="https://gfycat.com/adolescentidioticgoldeneye">via Gfycat</a></p>
```

 via Gfycat

### Algorithm
**Definitions:**

Partial derivatives:
$$\frac{\partial E_d}{\partial w_{ij}^k} = \delta_j^k o_i^{k-1}.\label{eq:1}$$

Final layer's error:
$$\delta_1^m = g_o^{\prime}(a_1^m)\left(\hat{y_d}-y_d\right).\label{eq:2}$$

Hidden layer's error:
$$\delta_j^k = g^{\prime}\big(a_j^k\big)\sum_{l=1}^{r^{k+1}}w_{jl}^{k+1}\delta_l^{k+1}.\label{eq:3}$$

Combining the partial derivatives for each input-output pair,
$$\frac{\partial E(X, \theta)}{\partial w_{ij}^k} = \frac{1}{N}\sum_{d=1}^N\frac{\partial}{\partial w_{ij}^k}\left(\frac{1}{2}\left(\hat{y_d} - y_d\right)^{2}\right) = \frac{1}{N}\sum_{d=1}^N\frac{\partial E_d}{\partial w_{ij}^k}.\label{eq:4}$$

Weight update:
$$\Delta w_{ij}^k = - \alpha \frac{\partial E(X, \theta)}{\partial w_{ij}^k}.\label{eq:5}$$

 1. Calculate the forward phase for each input-output pair $(\vec{x_d}, y_d)$ and store the results $\hat{y_d}$, $a_j^k$, and $o_j^k$ for each node $j$ in layer $k$ by proceeding from layer $0$, the input layer, to layer $m$, the output layer.

 1. Calculate the backward phase for each input-output pair $(\vec{x_d}, y_d)$ and store the results $\frac{\partial E_d}{\partial w_{ij}^k}$ for each weight $w_{ij}^k$ connecting node $i$ in layer $k-1$ to node $j$ in layer $k$ by proceeding from layer $m$, the output layer, to layer $1$, the input layer.

    - Evaluate the error term for the final layer $\delta_1^m$ by using equation (\ref{eq:2}).
    - Backpropagate the error terms for the hidden layers $\delta_j^k$, working backwards from the final hidden layer $k = m-1$, by repeatedly using equation (\ref{eq:3}).
    - Evaluate the partial derivatives of the individual error $E_d$ with respect to $w_{ij}^k$ by using  equation (\ref{eq:1}).

 1. Combine the individual gradients for each input-output pair $\frac{\partial E_d}{\partial w_{ij}^k}$ to get the total gradient $\frac{\partial E(X, \theta)}{\partial w_{ij}^k}$ for the entire set of input-output pairs $X = \big\{(\vec{x_1}, y_1), \dots, (\vec{x_N}, y_N) \big\}$ by using equation (\ref{eq:4}) (a simple average of the individual gradients).

 1. Update the weights according to the learning rate α\alpha and total gradient $\frac{\partial E(X, \theta)}{\partial w_{ij}^k}$ by using equation (\ref{eq:5}) (moving in the direction of the negative gradient).

In the example below, the matrix $X$ is the set of inputs $\vec{x}$ and the matrix y is the set of outputs $y$. The number of nodes in the hidden layer can be customized by setting the value of the variable `num_hidden`. The learning rate $\alpha$ is controlled by the variable `alpha`. The number of iterations of gradient descent is controlled by the variable `num_iterations`.

By changing these variables and comparing the output of the program to the target values $y$, one can see how these variables control how well backpropagation can learn the dataset $X$ and y. For example, more nodes in the hidden layer and more iterations of gradient descent will generally improve the fit to the training dataset. However, using too large or too small a learning rate can cause the model to diverge or converge too slowly, respectively.
Adapted from: `Backpropagation. Brilliant.org`. Retrieved 08:32, August 31, 2021, from https://brilliant.org/wiki/backpropagation/

```python
import numpy as np

# define the sigmoid function
def sigmoid(x, derivative=False):

    if (derivative == True):
        return sigmoid(x,derivative=False) * (1 - sigmoid(x,derivative=False))
    else:
        return 1 / (1 + np.exp(-x))

# choose a random seed for reproducible results
np.random.seed(1)

# learning rate
alpha = .1

# number of nodes in the hidden layer
num_hidden = 3

# inputs
X = np.array([
    [0, 0, 1],
    [0, 1, 1],
    [1, 0, 0],
    [1, 1, 0],
    [1, 0, 1],
    [1, 1, 1],
])

# outputs
# x.T is the transpose of x, making this a column vector
y = np.array([[0, 1, 0, 1, 1, 0]]).T

# initialize weights randomly with mean 0 and range [-1, 1]
# the +1 in the 1st dimension of the weight matrices is for the bias weight
hidden_weights = 2*np.random.random((X.shape[1] + 1, num_hidden)) - 1
output_weights = 2*np.random.random((num_hidden + 1, y.shape[1])) - 1

# number of iterations of gradient descent
num_iterations = 10000
outputs=[]
# for each iteration of gradient descent
for i in range(num_iterations):

    # forward phase
    # np.hstack((np.ones(...), X) adds a fixed input of 1 for the bias weight
    input_layer_outputs = np.hstack((np.ones((X.shape[0], 1)), X))
    hidden_layer_outputs = np.hstack((np.ones((X.shape[0], 1)), sigmoid(np.dot(input_layer_outputs, hidden_weights))))
    output_layer_outputs = np.dot(hidden_layer_outputs, output_weights)

    # backward phase
    # output layer error term
    output_error = output_layer_outputs - y
    # hidden layer error term
    # [:, 1:] removes the bias term from the backpropagation
    hidden_error = hidden_layer_outputs[:, 1:] * (1 - hidden_layer_outputs[:, 1:]) * np.dot(output_error, output_weights.T[:, 1:])

    # partial derivatives
    hidden_pd = input_layer_outputs[:, :, np.newaxis] * hidden_error[: , np.newaxis, :]
    output_pd = hidden_layer_outputs[:, :, np.newaxis] * output_error[:, np.newaxis, :]

    # average for total gradients
    total_hidden_gradient = np.average(hidden_pd, axis=0)
    total_output_gradient = np.average(output_pd, axis=0)

    # update weights
    hidden_weights += - alpha * total_hidden_gradient
    output_weights += - alpha * total_output_gradient
    outputs.append(output_layer_outputs)

# print the final outputs of the neural network on the inputs X
print("Output After Training: \n{}".format(output_layer_outputs))
plt.plot(np.hstack(outputs).T);
plt.legend([str(i) for i in y]);
```

```python
from scipy.integrate import odeint
```

## Continuous Hopfield Model
Hopfield equation where derived from Kirchoff's laws for electrical circuits.

$$\frac{d\overrightarrow{x}(t)}{dt}=-x(t) +Wa(t) +b$$

where x(t) is a vector of neuron activation levels, $W$ is the weigth matrix, b are the biases, and $a(t)=\phi(x(t))$.

### Two neuron example
\begin{align}
\dot{x}&=-x +\frac{2}{\pi}tan^{-1}\left(\frac{\gamma\pi y}{2}\right)\\
\dot{y}&=-y +\frac{2}{\pi}tan^{-1}\left(\frac{\gamma\pi x}{2}\right)
\end{align}

let

\begin{equation}
\nonumber
W=\begin{bmatrix}
0 & 1 \\
1 & 0 \\
\end{bmatrix},
b=\begin{bmatrix}
0\\
0
\end{bmatrix}, a1=2/\pi tan^{-1}
\end{equation}

```python
def hop2(Y,t):
    x,y = Y
    gamma = 20.5
    return -x+(2/np.pi)*np.arctan(gamma*np.pi*y/2), -y+(2/np.pi)*np.arctan(gamma*np.pi*x/2)
```

```python
res = odeint(hop2,[0.5,0.5], range(100))
```

```python
plt.plot(res);
```

## Discrete Hopfield Model

```python
from sympy import Matrix, eye
import random

# The fundamental memories:
x1 = [1, 1, 1, 1, 1]
x2 = [1, -1, -1, 1, -1]
x3 = [-1, 1, -1, 1, 1]

X = Matrix([x1, x2, x3])
W = X.T * X / 5 - 3*eye(5) / 5

def hsgn(v, x):
    if v > 0:
        return 1
    elif v == 0:
        return x
    else:
        return -1

L = [0, 1, 2, 3, 4]
n = random.sample(L, len(L))

xinput = [1, -1, -1, 1, 1]
xtest = xinput
for j in range(4):
    M = W.row(n[j]) * Matrix(xtest)
    xtest[n[j]] = hsgn(M[0], xtest[n[j]])

if xtest == x1:
    print('Net has converged to X1')
elif xtest == x2:
    print('Net has converged to X2')
elif xtest == x3:
    print('Net has converged to X3')
else:
    print('Iterate again: May have converged to spurious state')
```

    Net has converged to X2

```python
# Program 20c: Iteration of the minimal chaotic neuromodule.
# See Figure 20.13.

import matplotlib.pyplot as plt
import numpy as np

# Parameters
b1, b2, w11, w21, w12, a = -2, 3, -20, -6, 6, 1
num_iterations = 10000

def neuromodule(X):
    x,y=X
    xn=b1+w11/(1+np.exp(-a*x))+w12/(1+np.exp(-a*y))
    yn=b2+w21/(1+np.exp(-a*x))
    return xn,yn

X0 = [0, 2]
X, Y = [], []

for i in range(num_iterations):
    xn, yn = neuromodule(X0)
    X, Y = X + [xn], Y + [yn]
    X0 = [xn, yn]

fig, ax = plt.subplots(figsize=(8, 8))
ax.scatter(X, Y, color='blue', s=0.1)
plt.xlabel('x', fontsize=15)
plt.ylabel('y', fontsize=15)
plt.tick_params(labelsize=15)
plt.show()

```

```python
# Program 20d: Bifurcation diagram of the neuromodule.
# See Figure 20.16.

from matplotlib import pyplot as plt
import numpy as np

# Parameters
b2, w11, w21, w12, a = 3, 7, 5, -4, 1
start, max = -5, 10
half_N = 1999
N = 2 * half_N + 1
N1 = 1 + half_N
xs_up, xs_down = [], []
x, y = -10, -3
ns_up = np.arange(half_N)
ns_down = np.arange(N1, N)

# Ramp b1 up
for n in ns_up:
    b1 = start + n*max / half_N
    x = b1 + w11 / (1 + np.exp(-a*x)) + w12 / (1 + np.exp(-a*y))
    y = b2+w21 / (1 + np.exp(-a*x))
    xn = x
    xs_up.append([n, xn])
xs_up = np.array(xs_up)

# Ramp b1 down
for n in ns_down:
    b1 = start + 2*max - n*max / half_N
    x = b1 + w11 / (1 + np.exp(-a*x)) + w12 / (1 + np.exp(-a*y))
    y = b2 + w21 / (1 + np.exp(-a*x))
    xn = x
    xs_down.append([N-n, xn])
xs_down = np.array(xs_down)

fig, ax = plt.subplots()
xtick_labels = np.linspace(start, max, 7)
ax.set_xticks([(-start + x) / max * N1 for x in xtick_labels])
ax.set_xticklabels(['{:.1f}'.format(xtick) for xtick in xtick_labels])

plt.plot(xs_up[:, 0], xs_up[:, 1], 'r.', markersize=0.1)
plt.plot(xs_down[:, 0], xs_down[:,1], 'b.', markersize=0.1)
plt.xlabel(r'$b_1$', fontsize=15)
plt.ylabel(r'$x_n$', fontsize=15)
plt.tick_params(labelsize=15)
plt.show()

```

```python

```


```python
import matplotlib.pyplot as plt
import scipy.stats as st
import seaborn as sns
import pandas as pd
from scipy.stats import norm, uniform, expon, t, probplot
import scipy.stats as st
from scipy.integrate import quad
from sympy.solvers import solve
from sympy import Symbol
import numpy as np
from pandas import Series, DataFrame
```

```python
np.random.seed(1)
methode_B = np.array([80.02, 79.94, 79.98, 79.97, 79.97, 80.03, 79.95,
79.97])
# Arithmetisches Mittel der Messreihe methode_B
print('Arithmetisches Mittel von Messreihe Methode B:', methode_B.mean())
# Länge n der Messreihe methode_B
n = methode_B.size
# Anzahl Bootstrap samples
nboot = 1
# Bootstrap Sample wird aus Messreihe durch zufälliges
# Ziehen mit Zurücklegen generiert
bootstrap_sample = np.random.choice(methode_B, n*nboot, replace=True)
bootstrap_sample
print('Bootstrap Sample : ' , bootstrap_sample)
# Arithmetisches Mittel des Bootstrap Sample
print('Arithmetisches Mittel von Bootstrap Sample:' , bootstrap_sample.mean())
```

    Arithmetisches Mittel von Messreihe Methode B: 79.97874999999999
    Bootstrap Sample :  [80.03 79.97 79.97 80.02 79.97 79.94 79.97 80.03]
    Arithmetisches Mittel von Bootstrap Sample: 79.9875

```python
methode_B = np.array([80.02, 79.94, 79.98, 79.97, 79.97, 80.03, 79.95,
79.97])
n = methode_B.size
nboot = 1
bootstrap_sample = np.random.choice(methode_B, n*nboot, replace=True)
bootstrap_sample
print('Bootstrap Sample : ' , bootstrap_sample)
```

    Bootstrap Sample :  [79.97 80.02 80.02 79.94 79.97 79.97 80.03 79.97]

```python
print('Bootstrap Sample : ' , bootstrap_sample.mean())
```

    Bootstrap Sample :  79.98625

```python
np.random.seed(1)
methode_B = np.array([80.02, 79.94, 79.98, 79.97, 79.97, 80.03,79.95, 79.97])
n = methode_B.size
nboot = 20
bootstrap_sample = np.random.choice(methode_B, n*nboot, replace=True)
bootstrap_sample_array = np.reshape(bootstrap_sample, (n, nboot))
bootstrap_sample_array
xbarstar = bootstrap_sample_array.mean(axis=0)
np.sort(xbarstar)
print(np.sort(xbarstar))
```

    [79.96375 79.965   79.96625 79.96875 79.97125 79.97375 79.97625 79.9775
     79.97875 79.98    79.98125 79.98125 79.9825  79.9825  79.98375 79.985
     79.985   79.9875  79.99    79.99375]

```python
xbarstar = bootstrap_sample_array.mean(axis=0)
d = np.percentile(xbarstar, q=[2.5, 97.5])
print('Vertrauensintervall: ',d)
```

    Vertrauensintervall:  [79.96434375 79.99196875]

```python
tempdata = np.random.choice(methode_B, n*nboot, replace=True)
bootstrap_sample_array = np.reshape(tempdata, (n, nboot))
xbarstar = bootstrap_sample_array.mean(axis=0)
xbar = methode_B.mean()
delta = np.sort(xbarstar - xbar)
print('Abweichungen: ',delta)
```

    Abweichungen:  [-1.87500000e-02 -1.50000000e-02 -8.75000000e-03 -6.25000000e-03
     -6.25000000e-03 -5.00000000e-03 -5.00000000e-03 -2.50000000e-03
     -1.25000000e-03 -1.25000000e-03  1.42108547e-14  1.42108547e-14
      1.42108547e-14  2.50000000e-03  2.50000000e-03  5.00000000e-03
      6.25000000e-03  1.00000000e-02  1.25000000e-02  1.37500000e-02]

```python
tempdata = np.random.choice(methode_B, n*nboot, replace=True)
bootstrap_sample_array = np.reshape(tempdata, (n, nboot))
xbarstar = bootstrap_sample_array.mean(axis=0)
delta = np.sort(xbarstar - xbar)
d = np.percentile(delta, q=[2.5, 97.5])
print('0.025 und 0.975 Quantilen der Abweichungen: ',d)
```

    0.025 und 0.975 Quantilen der Abweichungen:  [-0.01446875  0.01578125]

```python
np.random.seed(1)
methode_B = np.array([80.02, 79.94, 79.98, 79.97, 79.97, 80.03,
79.95, 79.97])
n = methode_B.size
nboot = 10000
bootstrap_sample = np.random.choice(methode_B, n*nboot, replace=True)
bootstrap_sample_array = np.reshape(bootstrap_sample, (n, nboot))
bootstrap_sample_array
xbarstar = bootstrap_sample_array.mean(axis=0)
d = np.percentile(xbarstar, q=[2.5, 97.5])
print('Vertrauensintervall: ',d)
```

    Vertrauensintervall:  [79.96    79.99875]

```python
x = np.array([80.02, 79.94, 79.98, 79.97, 79.97, 80.03, 79.95, 79.97])
n = x.size
np.random.seed(1)
xbar = x.mean()
nboot = 10000
tmpdata = np.random.choice(x, n*nboot, replace=True)
bootstrapsample = np.reshape(tmpdata, (n, nboot))
xbarstar = Series(bootstrapsample.mean(axis=0))
plt.subplot(121)
xbarstar.plot(kind="hist", edgecolor="black")
plt.subplot(122)
probplot(xbarstar, plot=plt)
```

```python
n = x.size
np.random.seed(1)
standardabweichung_der_messdaten = x.mean()
nboot = 10000
tmpdata = np.random.choice(x, n*nboot, replace=True)
bootstrapsample = np.reshape(tmpdata, (n, nboot))
xbarstar = Series(bootstrapsample.mean(axis=0))
d = xbar - 2*xbarstar.std() , xbar + 2*xbarstar.std()
print('Vertrauensintervall: ',d)
```

    Vertrauensintervall:  (79.95819874674378, 79.9993012532562)

```python
x = np.random.normal(loc=40,scale=5,size=100)
n = x.size
np.random.seed(4)
xbar = x.mean()
# Anzahl Bootstrap Stichproben
nboot = 20
# Erzeuge Bootstrap Stichproben
bootstrap_samples = np.random.choice(x, n*nboot, replace=True)
bootstrap_sample_array = np.reshape(bootstrap_samples, (n, nboot))
# Arithmetisches Mittel für jede Bootstrap Stichprobe
xbarstar = bootstrap_sample_array.mean(axis=0)
# 2.5% und 97.5% Quantilen der Mittelwerte
# der Bootstrap Samples
ci = np.percentile(xbarstar, q=[2.5, 97.5])
# Vertrauensintervalle
print("Vertrauensintervall: ",ci)
```

    Vertrauensintervall:  [39.59026732 41.39130025]

```python
# Wir erzeugen 100'000 normalverteilte Zufallszahlen
# mit Mittelwert 40 und Standardabweichung 5
x = np.random.normal(loc=40, scale=5, size=10000)
# Wir ordnen diese Zahlen in einem Array an, der aus 100 Zeilen
# und 100 Spalten besteht
measurement_array = np.reshape(x,(100,100))
print(measurement_array.shape)
```

    (100, 100)

```python
print(measurement_array[1].size)
# Anzahl Bootstrap Samples
nboot = 1000
# Länge von jeder Bootstrap Stichprobe
n = 100
# k zählt Anzahl Vertrauensintervalle, die das
# wahre mu=40 nicht enthalten
k=0
# Wir iterieren über alle 100 Testreihen und bestimmen für jede
# Testreihe ein Vertrauensintervall (mittels bootstrap)
for i in range(0,100):
    x = measurement_array[i]
    # Arithmetisches Mittel pro Zeile im Array wird berechnet
    xbar = x.mean()
    # für die Zeile x wird nun ein Vertrauensintervall
    # mittels Bootstrap konstruiert
    bootstrap_samples = np.random.choice(x, n*nboot, replace=True)
    bootstrap_sample_array = np.reshape(bootstrap_samples, (n, nboot))
    xbarstar = bootstrap_sample_array.mean(axis=0)
    d = np.percentile(xbarstar, q=[2.5, 97.5])
    # Falls 40 im Vertrauensintervall für Zeile i NICHT enthalten ist
    # wird k um 1 erhöht
    if d[0]<= 40 <= d[1]:
        k=k+1
print(k)
```

    100
    94

```python
#np.random.seed(8)
# Wir generieren 10'000 normalverteilte Zufallszahlen
# mit Mittelwert 40 und Standardabweichung 5
x = np.random.normal(loc=40, scale=5, size=10000)
# Wir ordnen die Zufallszahlen in einem array mit 100 Spalten
# und 100 Zeilen an
measurement_array = np.reshape(x,(100,100))
print(measurement_array.shape)
# Anzahl Bootstrap Samples
nboot = 10000
n = 100
# Wir iterieren über die 100 Testreihen
for i in range(0,100):
    # wir lesen die i-te Zeile aus dem measurement_array heraus
    y = measurement_array[i]
    # Bestimmung des Vertrauensintervalls der i-ten Testreihe
    tmpdata = np.random.choice(y, n*nboot, replace=True)
    bootstrapsample = np.reshape(tmpdata, (n, nboot))
    xbarstar = bootstrapsample.mean(axis=0)
    ci = np.percentile(xbarstar, q=[2.5, 97.5])
    plt.plot([i,i],[ci[0], ci[1]])
    if (ci[0]<= 40 <= ci[1])==False:
        plt.plot([i,i],[ci[0], ci[1]], c="black",linewidth=3)
        plt.plot([-5,105],[40,40])
plt.show()
```

```python

```

```python

```

```python

```


```python
import numpy as np
import matplotlib.pyplot as plt
import sys
%load_ext autoreload
%autoreload 0
```

Notebook by **Maxime Dion** <maxime.dion@usherbrooke.ca>

For the QSciTech-QuantumBC virtual workshop on gate-based quantum computing

## Tutorial for  Activity 3.1

For this activity, make sure you can easily import your versions of `hamiltonian.py`, `pauli_string.py` and `mapping.py`. Placing this notebook in the same `path` as these files is the easiest way to acheive this. At the end of this notebook, you should be in good position to complete these 3 files.

The solution we suggest here is NOT mandatory. If you find ways to make it better and more efficient, go on and impress us! On the other hand, by completing all sections of this notebook you be able to :
- Create `PauliString` instances
- Multiply `PauliString` together
- Translate a PauliString into a unitary matrix of size `(2**n)**2` (optionnal)
- Create `LinearCombinaisonPauliString` instances
- Multiply and add `LinearCombinaisonPauliString` together
- Combine repeated `PauliString`s in `LinearCombinaisonPauliString`
- Use the Jordan-Wigner mapping to translate Fermionic Operators in `LinearCombinaisonPauliString`s
- Use the Jordan-Wigner mapping to map a `FermionicHamiltonian` into a `LinearCombinaisonPauliString`.

**Important**

When you modify and save a `*.py` file you need to re-import it so that your modifications can be taken into account when you re-execute a call. By adding the magic command `%autoreload` at the beginning of a cell, you make sure that the modifications you did to the `*.py` files are taken into account when you re-run a celll and that you can see the effect.

If you encounter unusual results, restart the kernel and try again.

**Note on numbering**

When you ask a question in the Slack channel you can refer to the section name or the section number.

To enable the section numbering, please make sure you install [nbextensions](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/install.html). It is available in the conda distribution. After installation it you need to enable the option 'Table of contents (2)'.

# PauliStrings

The `PauliString` class is partially implemented in the file `PauliString.py`.

```python
from pauli_string import PauliString
```

## Creation

This object's attributes are 2 arrays of booleans `z_bits` and `x_bits`. You can easily create an instance and `print` the result. The `__str__` method is already implemented so you can use `print()` on it. The boolean arrays in input are in the `0123` order and the output string is in the reversed `q3q2q1q0` order. Here we initialize the Pauli string `YXZI = -1j*(ZIZI)*(XXII)`.

```python
z_bits = np.array([0,1,0,1],dtype = bool)
x_bits = np.array([0,0,1,1],dtype = bool)
pauli_string = PauliString(z_bits,x_bits)
print(pauli_string)
```

    YXZI

### Creation exercice

Create the `ZZXY` pauli string. Remember that the arrays are in the 0...n order, but the string representation in the reverse order. The `print` should return `ZZXY`.

```python
z_bits = np.array([1,0,1,1], dtype=bool)
x_bits = np.array([1,1,0,0], dtype=bool)
print(PauliString(z_bits,x_bits))
```

    ZZXY

### Creation from string
While this will not be a useful way to create PauliString in the H2 solution, it will be practical for this notebook and for debugging. Implement the `@classmethod` method `from_str` that takes a string like `YXZI` to build a `PauliString`.

```python
%autoreload
pauli_string = PauliString.from_str('YXZI')
print(pauli_string)
```

    YXZI

### Useful methods

In order to compare `PauliString`s together it's convenient to represent it as `zx_bits` which is an array twice as long that combine `z_bits` and `x_bits`. Implement the `to_zx_bits()` method. Why not do the `to_xz_bits()` while your at it!

You should get :

[False  True False  True False False  True  True]
[False False  True  True False  True False  True]

```python
%autoreload
pauli_string = PauliString.from_str('YXZI')
zx_bits = pauli_string.to_zx_bits()
print(zx_bits)
xz_bits = pauli_string.to_xz_bits()
print(xz_bits)
```

    [False  True False  True False False  True  True]
    [False False  True  True False  True False  True]

It's also useful to know where are the $\hat{I}$ in a `PauliString`. Implement the method that does this. You should get

[ True False False False]

```python
%autoreload
pauli_string = PauliString.from_str('YXZI')
ids = pauli_string.ids()
print(ids)
```

    [ True False False False]

### Create with `zx`

Sometimes it will be convenient to create a `PauliString` from a single array `zx_bits`. Implement the `from_zx_bits()` method in order for this cell to return `YXZI`.

```python
%autoreload
z_bits = np.array([0,1,0,1],dtype = bool)
x_bits = np.array([0,0,1,1],dtype = bool)
zx_bits = np.concatenate((z_bits,x_bits))
pauli_string = PauliString.from_zx_bits(zx_bits)
print(pauli_string)
```

    YXZI

## Multiplication with another PauliString

Multiplying `PauliString`s is essential to be able to translate Fermionic Hamiltonians into a qubit Hamiltonian.

Before you implement the method that will allow you to do this, you should experiment a bit with how boolean arrays behave. Take a look at methods like `np.dot()`, `np.logical_and()`, `np.logical_or()` and `np.logical_xor()`. In particular, notice that the addition `+` on booleans is not a (mod 2) addition (it's a `logical_or`) and the `np.sum()` method on a boolean array counts the number of 1 and returns an `int`.

```python
bits_1 = np.array([0,1,0,1],dtype = bool)
bits_2 = np.array([0,1,1,1],dtype = bool)
print(bits_1 + bits_2)
print(np.sum(bits_1))
# Experiment
print(np.dot(bits_1, bits_2))
print(np.logical_and(bits_1, bits_2))
print(np.logical_or(bits_1, bits_2))
print(np.logical_xor(bits_1, bits_2))
print(np.sum(np.logical_and(bits_1, bits_2)))    # the "dot product" we need
```

    [False  True  True  True]
    2
    True
    [False  True False  True]
    [False  True  True  True]
    [False False  True False]
    2

With these considarations, implement the `mul_pauli_string(PauliString)` method in order to replicate the product

\begin{align}
\hat{I}\hat{Y}\hat{Z}\hat{Z} \times \hat{I}\hat{I}\hat{X}\hat{Z}  = i \hat{I}\hat{Y}\hat{Y}\hat{I}.
\end{align}

The product return a `PauliString` and a phase (`complex`). The method `__mul__(other)` is already implemeted to call `mul_pauli_string()` so you can use `*` to do the product.

```python
%autoreload
pauli_string_1 = PauliString.from_str('IYZZ')
pauli_string_2 = PauliString.from_str('IIXZ')
new_pauli_string, phase = pauli_string_1 * pauli_string_2
print(new_pauli_string, phase)
```

    IYYI 1j

Check your solution on many pairs of Pauli strings like

\begin{align}
\hat{Z}\hat{Z}\hat{Z}\hat{Z} \times \hat{X}\hat{X}\hat{X}\hat{I}  = -i \hat{Y}\hat{Y}\hat{Y}\hat{Z}.
\end{align}

```python
%autoreload
pauli_string_1 = PauliString.from_str('ZZZZ')
pauli_string_2 = PauliString.from_str('XXXI')
new_pauli_string, phase = pauli_string_1 * pauli_string_2
print(new_pauli_string, phase)
```

    YYYZ (-0-1j)

## Matrix representation (optional)
The matrix reprensetation of `PauliString` will only be used to compute the exact solution of the Hamiltonian. It will not be used for quantum computing, but it's a nice way to validate your results.

Any Pauli string can be converted into a matrix. This is useful to find the exact solution for small systems. To combine the space of two qubits we use the [Kronecker product](https://en.wikipedia.org/wiki/Kronecker_product) ($\otimes$). For example, the `ZX` pauli string can be represented as the following matrix

\begin{align}
    \hat{Z}_1\hat{X}_0 = \hat{Z}_1\otimes\hat{X}_0 &= \begin{pmatrix} 1 \times \hat{X}_0 & 0 \\ 0 & -1 \times \hat{X}_0 \end{pmatrix} \\
    &= \begin{pmatrix} 0 & 1 & 0 & 0 \\ 1 & 0 & 0 & 0 \\ 0 & 0 & 0 & -1 \\ 0 & 0 & -1 & 0 \end{pmatrix}
\end{align}

which is expressed in the basis

\begin{align}
    |00\rangle, |01\rangle, |10\rangle, |11\rangle.
\end{align}

Indeed we verify that

\begin{align}
    \hat{Z}_1\hat{X}_0 |10\rangle &= - |11\rangle \\
    \begin{pmatrix} 0 & 1 & 0 & 0 \\ 1 & 0 & 0 & 0 \\ 0 & 0 & 0 & -1 \\ 0 & 0 & -1 & 0 \end{pmatrix}\begin{pmatrix} 0 \\ 0 \\ 1 \\ 0 \end{pmatrix} &= -\begin{pmatrix} 0 \\ 0 \\ 0 \\ 1 \end{pmatrix}
\end{align}

The `np.kron()` method is a good way to acheive this. The previous 2 Pauli string can be turned into a $4\times 4$ matrix like this.

```python
z_matrix = np.array([[1,0],[0,-1]],dtype = int)
x_matrix = np.array([[0,1],[1,0]],dtype = int)
print(z_matrix)
print(x_matrix)
zx_matrix = np.kron(z_matrix,x_matrix)
print(zx_matrix)

```

    [[ 1  0]
     [ 0 -1]]
    [[0 1]
     [1 0]]
    [[ 0  1  0  0]
     [ 1  0  0  0]
     [ 0  0  0 -1]
     [ 0  0 -1  0]]

Implement the `to_matrix()` method for any Pauli string and try it to find the matrix form of `ZX`.

```python
%autoreload
pauli_string = PauliString.from_str('ZX')
print(pauli_string)
matrix = pauli_string.to_matrix()
print(matrix)
```

    ZX
    [[ 0.+0.j  1.+0.j  0.+0.j  0.+0.j]
     [ 1.+0.j  0.+0.j  0.+0.j  0.+0.j]
     [ 0.+0.j  0.+0.j -0.+0.j -1.+0.j]
     [ 0.+0.j  0.+0.j -1.+0.j -0.+0.j]]

# LinearCombinaisonPauliString
The `LinearCombinaisonPauliString` class is partially implemented in the file `pauli_string.py`. We will use `LCPS` for short.

```python
from pauli_string import LinearCombinaisonPauliString
```

## Creation
To build a `LCPS` you only need to provide an `numpy.array` of coefficients (`complex`) and a `numpy.array` of PauliString. If they are not arrays, they will be converted. Here again the `__str__()` method is already implemented.

```python
coefs = np.array([0.5,0.5],dtype=complex)
pauli_string_1 = PauliString.from_str('IIXZ')
pauli_string_2 = PauliString.from_str('IYZZ')
pauli_strings = np.array([pauli_string_1,pauli_string_2], dtype=PauliString)
lcps = LinearCombinaisonPauliString(coefs,pauli_strings)
print(lcps)
```

    2 pauli strings for 4 qubits (Real, Imaginary)
    IIXZ (+0.50000,+0.00000)
    IYZZ (+0.50000,+0.00000)

### Multiplication of a PauliString by a coefficient
Multiplying a `PauliString` by a number is a useful way to create a `LCPS` with only 1 `PauliStrin`. Implement the method `mul_coef` in the `PauliString` class so that you can easily create a `LCPS`.

```python
%autoreload
lcps_single = 1*PauliString.from_str('IIXZ')
print(lcps_single)
```

    1 pauli strings for 4 qubits (Real, Imaginary)
    IIXZ (+1.00000,+0.00000)

### Addition of LCPSs
The sum of two `LCPS`s is just the union of these two ensembles. Implement `add_pauli_string_linear_combinaison(LCPS)` and test your solution here. The `__add__()` method is already implemented to call `add_pauli_string_linear_combinaison(LCPS)` so you can use the `+` operator.

```python
%autoreload
lcps = 0.5*pauli_string_1 + 0.5*pauli_string_2
print(lcps)
```

    2 pauli strings for 4 qubits (Real, Imaginary)
    IIXZ (+0.50000,+0.00000)
    IYZZ (+0.50000,+0.00000)

### Product of LCPSs
The product of two LCPSs can be computed using the distributive property of any sum. While you implement `mul_linear_combinaison_pauli_string` make sure you take into account the phase coming from the product of two PauliStrings. You can test your code on the following cell.

```python
%autoreload
lcps_1 = 1*PauliString.from_str('IIXZ')
lcps_2 = 1*PauliString.from_str('IYZZ')
new_lcps = lcps_1 * lcps_2
print(new_lcps)
```

    1 pauli strings for 4 qubits (Real, Imaginary)
    IYYI (+0.00000,-1.00000)

You should get:

1 pauli strings for 4 qubits (Real, Imaginary)
IYYI (+0.00000,-1.00000)

With addition and multiplication, LCPSs are much more convenient to work with than PauliStrings because they carry the possible phase from the product.

## Accessing subset of a LCPS
A `__getitem__()` method is already implemented to access subset of the `LCPS`. You can use indices and slices, like a `list` or an `np.array`.

```python
lcps = 1*PauliString.from_str('IIIZ') + 1*PauliString.from_str('IIZI') + 1*PauliString.from_str('IZII') + 1*PauliString.from_str('ZIII')
print(lcps[0])
print(lcps[1:3])
print(lcps[-1])
print(lcps[np.array([False, True, True, False])])
```

    1 pauli strings for 4 qubits (Real, Imaginary)
    IIIZ (+1.00000,+0.00000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    IIZI (+1.00000,+0.00000)
    IZII (+1.00000,+0.00000)
    1 pauli strings for 4 qubits (Real, Imaginary)
    ZIII (+1.00000,+0.00000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    IIZI (+1.00000,+0.00000)
    IZII (+1.00000,+0.00000)

## Useful methods
Your already implemented `to_zx_bits`, `to_xz_bits` and `ids` for the PauliString. Implement similar methods for LCPS where the bits are stored in a dim = 2 array.

```python
%autoreload
print('zx_bits')
zx_bits = lcps.to_zx_bits()
print(zx_bits)
```

    zx_bits
    [[ True False False False False False False False]
     [False  True False False False False False False]
     [False False  True False False False False False]
     [False False False  True False False False False]]

```python
%autoreload
print('xz_bits')
xz_bits = lcps.to_xz_bits()
print(xz_bits)
```

    xz_bits
    [[False False False False  True False False False]
     [False False False False False  True False False]
     [False False False False False False  True False]
     [False False False False False False False  True]]

```python
%autoreload
print('ids')
ids = lcps.ids()
print(ids)
```

    ids
    [[False  True  True  True]
     [ True False  True  True]
     [ True  True False  True]
     [ True  True  True False]]

## Combinaison and threshold
When a `PauliString` is present many times in a LCPS, it is convenient to be able to remove extra occurences by combining the respective coefficients. Let's take the example from the presentation.

```python
lcps_1 = 1*PauliString.from_str('IIIZ') + -0.5*PauliString.from_str('IIZZ')
lcps_2 = 1*PauliString.from_str('ZZZI') + 0.5*PauliString.from_str('ZZII')
lcps_3 = lcps_1 * lcps_2
print(lcps_3)
```

    4 pauli strings for 4 qubits (Real, Imaginary)
    ZZZZ (+1.00000,+0.00000)
    ZZIZ (+0.50000,+0.00000)
    ZZIZ (-0.50000,+0.00000)
    ZZZZ (-0.25000,+0.00000)

We see that `ZZZZ` occurs 2 times and `ZZIZ` occurs 2 times as well.

Implement the `combine()` method to reduce the `LCPS` to 2 `PauliString`s. There are many ways to do that. Suggestion, convert with `to_zx_bits` and use the `np.unique()` method. DO NOT remove `PauliString`s with `0` coef yet.

```python
%autoreload
lcps_combined = lcps_3.combine()
print(lcps_combined)
```

    2 pauli strings for 4 qubits (Real, Imaginary)
    ZZIZ (+0.00000,+0.00000)
    ZZZZ (+0.75000,+0.00000)

You should get:

2 pauli strings for 4 qubits (Real, Imaginary)
ZZIZ (+0.00000,+0.00000)
ZZZZ (+0.75000,+0.00000)

Implement the `apply_threshold` method to get rid of any Pauli string with a coefficient smaller than the `threshold`.

```python
%autoreload
lcps = lcps_combined.apply_threshold()
print(lcps)
```

    1 pauli strings for 4 qubits (Real, Imaginary)
    ZZZZ (+0.75000,+0.00000)

You should get:

1 pauli strings for 4 qubits (Real, Imaginary)
ZZZZ (+0.75000,+0.00000)

## Sort
The resulting LCPS after `combine` and `apply_threshold` can be pretty random. It's useful to be able to sort. We suggest you implement the `sort` method using the `zx_bits`.

```python
%autoreload
lcps = (lcps_1 + lcps_2).sort()
print(lcps)
```

    4 pauli strings for 4 qubits (Real, Imaginary)
    IIIZ (+1.00000,+0.00000)
    IIZZ (-0.50000,+0.00000)
    ZZII (+0.50000,+0.00000)
    ZZZI (+1.00000,+0.00000)

## Matrix representation (optional)

The LCPS can be represented as a matrix. This matrix is just the linear combinaison of the matrices representing each Pauli string. Implement the `to_matrix()` method.

```python
%autoreload
small_lcps = 1*PauliString.from_str('ZZ') + 2*PauliString.from_str('XX')
matrix = small_lcps.to_matrix()
print(matrix)
```

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

You should get :

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

# Mapping
You are now in good position to implement your first mapping. The abstract class `Mapping` and the class `JordanWigner` are partially implemented in the file `Mapping.py`.

```python
from mapping import JordanWigner
```

## Jordan-Wigner
The goal of the mapping is to translate creation and annihilation fermionic operator into `LinearCombinaisonPauliString`. You now need to implement `fermionic_operator_linear_combinaison_pauli_string()` of the `JordanWigner` class. It should return 2 lists of `LinearCombinaisonPauliString`s, one `list` for the 4 creation operators and one `list` for the 4 annihilation operators. You can make use of the addition and multipliation method you implemented earlier.

Refer to the presentation of activity 3.1 for the general structure of the Jordan-Wigner mapping. Make sure your method works for different numbers of qubits.

```python
%autoreload
mapping = JordanWigner()
aps, ams = mapping.fermionic_operator_linear_combinaison_pauli_string(4)
print(len(aps), 'creation operators')
print(len(ams), 'annihilation operators')

print('Creation operators')
for ap in aps:
    print(ap)
print()
print('Annihilation operators')
for am in ams:
    print(am)
```

    4 creation operators
    4 annihilation operators
    Creation operators
    2 pauli strings for 4 qubits (Real, Imaginary)
    IIIX (+0.50000,+0.00000)
    IIIY (-0.00000,-0.50000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    IIXZ (+0.50000,+0.00000)
    IIYZ (-0.00000,-0.50000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    IXZZ (+0.50000,+0.00000)
    IYZZ (-0.00000,-0.50000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    XZZZ (+0.50000,+0.00000)
    YZZZ (-0.00000,-0.50000)

    Annihilation operators
    2 pauli strings for 4 qubits (Real, Imaginary)
    IIIX (+0.50000,+0.00000)
    IIIY (+0.00000,+0.50000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    IIXZ (+0.50000,+0.00000)
    IIYZ (+0.00000,+0.50000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    IXZZ (+0.50000,+0.00000)
    IYZZ (+0.00000,+0.50000)
    2 pauli strings for 4 qubits (Real, Imaginary)
    XZZZ (+0.50000,+0.00000)
    YZZZ (+0.00000,+0.50000)

For the creation operators you should get.

Creation operators
2 pauli strings for 4 qubits (Real, Imaginary)
IIIX (+0.50000,+0.00000)
IIIY (-0.00000,-0.50000)
2 pauli strings for 4 qubits (Real, Imaginary)
IIXZ (+0.50000,+0.00000)
IIYZ (-0.00000,-0.50000)
2 pauli strings for 4 qubits (Real, Imaginary)
IXZZ (+0.50000,+0.00000)
IYZZ (-0.00000,-0.50000)
2 pauli strings for 4 qubits (Real, Imaginary)
XZZZ (+0.50000,+0.00000)
YZZZ (-0.00000,-0.50000)

For the annihilation just reverse the sign of the imaginary part.

## Molecular Hamiltonian Check

To map the H2 `MolecularFermionicHamiltonian` we first need to get the integral tensors. You can use either one of the two solutions presented here depending on how succesful you were with the installation of the now infamous `pyscf` module.

- The first method will make use of `pyscf` to build the molecule and of the `from_pyscf_mol` method you have implemented from day 2.

- The second method will simply load the integral tensors from a file. We provide this file for a H2 molecule with a distance of 0.735 Angstroms. Even if you are using the first method, you can use this one to compare your tensor to the ones provided. In particuler, the two-body tensor can be a bit tricky to get right!

### Building the molecule with pyscf

**note** If you were not able to properly install and run `pyscf` on your computer, please skip this section.

Before going further, we need to make sure your `MolecularFermionicHamiltonian` implementation in `hamiltonian.py` is correct. By now you should be able to run the following cell to build a molecule with pyscf and convert it into a Fermionic Hamiltonian.

```python
%autoreload
from pyscf import gto
from hamiltonian import MolecularFermionicHamiltonian
```

```python
distance = 0.735 #units in AA
mol = gto.M(
    atom = [['H', (0,0,-distance/2)], ['H', (0,0,distance/2)]],
    basis = 'sto-3g'
    )
molecular_hamiltonian_no_spin = MolecularFermionicHamiltonian.from_pyscf_mol(mol)
print(molecular_hamiltonian_no_spin)
```

    Fermionic Hamiltonian
    Number of orbitals : 2
    Including spin : False

You should be able to access the integral tensors to check if their values are correct. The `get_integrals` methods for each Hamiltonian are already implemented.

```python
h1_no_spin, h2_no_spin = molecular_hamiltonian_no_spin.get_integrals()
```

```python
print(h1_no_spin)
```

    [[-1.25633907  0.        ]
     [ 0.         -0.47189601]]

Depending on the basis you've chosen, you should get

[[-1.25633907  0.        ]
 [ 0.         -0.47189601]]

If you have a different order, we strongly suggest to modify your implementation of the `from_pyscf_mol()` to your order matches this one. Getting the order of the eigenvalues with `numpy.argsort` and reorganizing the eigenvalues and the eigenvectors usually does the trick.

The `h2` tensor is large to look at. We will check its validity in next section.

**note** If you want to save the integrals into a file you can use something like the following cell. Saving the atom configuration, the basis and the nuclear energy can be useful.

```python
with open('my_file.npz','wb') as f:
    np.savez(f, atom=mol.atom, basis=mol.basis, energy_nuc=mol.energy_nuc(), h1=h1_no_spin, h2=h2_no_spin)
```

    /Users/bhenders/opt/miniconda3/envs/qiskit/lib/python3.8/site-packages/numpy/core/_asarray.py:136: 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
      return array(a, dtype, copy=False, order=order, subok=True)

### Loading the integrals from a file

The file containing the integrals is named `Integrals_sto-3g_H2_d_0.7350_no_spin.npz`. These integrals are already in the molecular basis but the spin bas not been included yet. Make sure it's present in the same path is this notebook or change the access path in `open` to reach it. To load the integrals just run the following cell.

```python
with open('Integrals_sto-3g_H2_d_0.7350_no_spin.npz','rb') as f:
    out = np.load(f, allow_pickle=True)
    h1_load_no_spin = out['h1']
    h2_load_no_spin = out['h2']

print(h1_load_no_spin)
```

    [[-1.25633907  0.        ]
     [ 0.         -0.47189601]]

By the way, this `npz` also contains files with the `basis`, the `atom` and `energy_nuc` names. While `atom` describes the position of the H atoms `energy_nuc` gives the Coulomb repulsion energy between the nuclei. You can only access it if you `allow_pickle` in the `load` method (check [here](https://numpy.org/devdocs/reference/generated/numpy.load.html) for more details on that).

**Note** If you built your tensor on your own you can check that yours are equivalent to the ones in the file. If you didn't, just skip this cell. If your integrals are different, maybe you should check you implementation of `from_pyscf_mol` before going further.

```python
np.allclose(h1_no_spin, h1_load_no_spin)
```

    True

```python
np.allclose(h2_no_spin, h2_load_no_spin)
```

    True

All we need to do now is put this into a `MolecularFermionicHamiltonian` with the `from_integrals` method.

```python
molecular_hamiltonian_no_spin = MolecularFermionicHamiltonian.from_integrals(h1_load_no_spin,h2_load_no_spin)
```

### Add the spin

Last step to make the `MolecularFermionicHamiltonian` complete is to include the spin to double the number of basis states.

```python
molecular_hamiltonian = molecular_hamiltonian_no_spin.include_spin()
```

This method is doing a [Kronecker product](https://en.wikipedia.org/wiki/Kronecker_product) between the integral tensors in the orbital space with the spin space. Since spin up and spin down are only just a copy of one an other, we simply use a identity matrix.

\begin{align}
    \mathbb{I}_{mn}^{\text{spin}} \otimes h_{st}^{(1) \text{orbital}} &=
    \begin{pmatrix} 1_\uparrow & 0 \\ 0 & 1_\downarrow  \end{pmatrix}\otimes \begin{pmatrix} E_g & 0 \\ 0 & E_u \end{pmatrix} \\
    &= \begin{pmatrix} 1_\uparrow \times h^{(1) \text{orbital}} & 0 \\ 0 & 1_\downarrow \times h^{(1) \text{orbital}} \end{pmatrix} \\
    h_{ij}^{(1) \text{spin-orbital}} &= \begin{pmatrix} E_{g\uparrow} & 0 & 0 & 0 \\ 0 & E_{u\uparrow} & 0 & 0 \\ 0 & 0 & E_{g\downarrow} & 0 \\ 0 & 0 & 0 & E_{u\downarrow} \end{pmatrix}
\end{align}

The index $m$, $n$, $s$ and $t$ can all take the values `0` and `1`. The Kronecker product combines $m$ and $s$ together to form $i$ while $n$ and $t$ are combined together to form $j$. Both $i$ and $j$ can now take 4 different values therefore the $4\times4$ matrix. The $\uparrow$ and $\downarrow$ in subscript are only there to identify each one belongs to which spin. In the end the values $E_{g\uparrow} = E_{g\downarrow} = E_{g}$ and $E_{u\uparrow} = E_{u\downarrow} = E_{u}$.

This a little more complex for the 2 body term because the tensor is of dimension 4.

\begin{align}
    \mathbb{I}_{mn}\mathbb{I}_{pq} \otimes h_{stuv}^{(2) \text{orbital}} &= h_{ijkl}^{(2) \text{spin-orbital}}
\end{align}

Obsiously we cannot easily write this! But here the indices are combined like so

\begin{align}
    (m,s) \to i, (n,t) \to j, (p,u) \to k, (q,v) \to l.
\end{align}

And if that is not complicated enough this when the $h_{stuv}^{(2) \text{orbital}}$ is given in the physicist notation. Remember that you can go from one notation to the other by simply using

    h2_physicist = np.einsum('ijkl->iklj',h2_chemist)

or

    h2_chemist = np.einsum('iklj->ijkl',h2_physicist)

## Building the Qubit Hamiltonian
The translation from a fermionic Hamltonian to a qubit Hamiltonian depends on the kind of Hamiltonian. That's why we will implement a version of the `to_linear_combinaison_pauli_string()` method for `OneBodyFermionicHamiltonian` and for `TwoBodyFermionicHamiltonian`. All we need to provide are the creation/annhilation operators you generated in the Jordan-Wigner section.

### One body term
Let's start with the one_body part. The one body Hamiltonian is of the form
\begin{align}
    \mathcal{H}_1 = \sum_{i,j} h_{ij} \hat{a}_i^\dagger \hat{a}_j
\end{align}

You should now be able to implement the `to_linear_combinaison_pauli_string(aps,ams)` method for the `OneBodyFermionicHamiltonian` class in the `Hamiltonian.py` file.

Each fermionic operator is made of 2 Pauli strings. So each term $i,j$ creates 4 Pauli strings. There is 16 terms in $h_{ij}$ so there is 64 Pauli strings (if we do not consider combinaison and applying threshold yet). Your implementation should now return a LCPS of length 64.

```python
%autoreload
print(lcps * np.array([1,2,2,1]))
```

    4 pauli strings for 4 qubits (Real, Imaginary)
    IIIZ (+1.00000,+0.00000)
    IIZZ (-1.00000,+0.00000)
    ZZII (+1.00000,+0.00000)
    ZZZI (+1.00000,+0.00000)

```python
%autoreload
h1_lcps = molecular_hamiltonian.one_body.to_linear_combinaison_pauli_string(aps, ams)
print(len(h1_lcps))
```

    64

We see many Pauli strings with 0 coefficient as well as many repeated strings. We can now exploit `combine()` and `apply_threshold()`. Since there is many 0 terms already we do `apply_threshold()` first, then `combine()` and `apply_threshold()` again if there was any cancellations. We can finish with `sort()` for neat presentation.

```python
h1_lcps = h1_lcps.combine().apply_threshold().sort()
print(h1_lcps)
```

    5 pauli strings for 4 qubits (Real, Imaginary)
    IIII (-1.72824,+0.00000)
    IIIZ (+0.62817,+0.00000)
    IIZI (+0.23595,+0.00000)
    IZII (+0.62817,+0.00000)
    ZIII (+0.23595,+0.00000)

You should get :

5 pauli strings for 4 qubits (Real, Imaginary)
IIII (-1.72824,+0.00000)
IIIZ (+0.62817,+0.00000)
IIZI (+0.23595,+0.00000)
IZII (+0.62817,+0.00000)
ZIII (+0.23595,+0.00000)

**Note** : You can add these steps at the end of your `one_body.to_linear_combinaison_pauli_string` to automatically reduce the number of Pauli strings.

### Two body term
The two body Hamiltonian is of the following form :
\begin{align}
    \mathcal{H}_2 = \frac{1}{2}\sum_{i,j} h_{ijkl} \hat{a}_i^\dagger\hat{a}_j^\dagger \hat{a}_k\hat{a}_l
\end{align}
You can now implement the `to_linear_combinaison_pauli_string()` method for the `TwoBodyFermionicHamiltonian` class in the `Hamiltonian.py` file.
Counting all Pauli strings you should produce 4096 Pauli strings.

```python
%autoreload
h2_lcps = molecular_hamiltonian.two_body.to_linear_combinaison_pauli_string(aps, ams)
print(len(h2_lcps))
```

    4096

Applying the `combine` and `apply_threshold` this reduce to 15 Pauli strings!

```python
h2_lcps = h2_lcps.combine().apply_threshold().sort()
print(h2_lcps)
```

    15 pauli strings for 4 qubits (Real, Imaginary)
    IIII (+0.91769,+0.00000)
    IIIZ (-0.45599,+0.00000)
    IIZI (-0.46170,+0.00000)
    IIZZ (+0.12091,+0.00000)
    IZII (-0.45599,+0.00000)
    IZIZ (+0.16893,+0.00000)
    IZZI (+0.16615,+0.00000)
    ZIII (-0.46170,+0.00000)
    ZIIZ (+0.16615,+0.00000)
    ZIZI (+0.17464,+0.00000)
    ZZII (+0.12091,+0.00000)
    XXXX (+0.04523,+0.00000)
    XXYY (+0.04523,+0.00000)
    YYXX (+0.04523,+0.00000)
    YYYY (+0.04523,+0.00000)

You should get :

15 pauli strings for 4 qubits (Real, Imaginary)
IIII (+0.91769,+0.00000)
IIIZ (-0.45599,+0.00000)
IIZI (-0.46170,+0.00000)
IIZZ (+0.12091,+0.00000)
IZII (-0.45599,+0.00000)
IZIZ (+0.16893,+0.00000)
IZZI (+0.16615,+0.00000)
ZIII (-0.46170,+0.00000)
ZIIZ (+0.16615,+0.00000)
ZIZI (+0.17464,+0.00000)
ZZII (+0.12091,+0.00000)
XXXX (+0.04523,+0.00000)
XXYY (+0.04523,+0.00000)
YYXX (+0.04523,+0.00000)
YYYY (+0.04523,+0.00000)

### Molecular Hamiltonian
The molecular Hamiltonian is just the sum of the one and two body terms. Implement this function in `MolecularFermionicHamiltonian`. You should now be able to run this code.

```python
%autoreload
htot_lcps = molecular_hamiltonian.to_linear_combinaison_pauli_string(aps, ams)
htot_lcps = htot_lcps.combine().apply_threshold().sort()
print(htot_lcps)
```

    15 pauli strings for 4 qubits (Real, Imaginary)
    IIII (-0.81055,+0.00000)
    IIIZ (+0.17218,+0.00000)
    IIZI (-0.22575,+0.00000)
    IIZZ (+0.12091,+0.00000)
    IZII (+0.17218,+0.00000)
    IZIZ (+0.16893,+0.00000)
    IZZI (+0.16615,+0.00000)
    ZIII (-0.22575,+0.00000)
    ZIIZ (+0.16615,+0.00000)
    ZIZI (+0.17464,+0.00000)
    ZZII (+0.12091,+0.00000)
    XXXX (+0.04523,+0.00000)
    XXYY (+0.04523,+0.00000)
    YYXX (+0.04523,+0.00000)
    YYYY (+0.04523,+0.00000)

You should get :

15 pauli strings for 4 qubits (Real, Imaginary)
IIII (-0.81055,+0.00000)
IIIZ (+0.17218,+0.00000)
IIZI (-0.22575,+0.00000)
IIZZ (+0.12091,+0.00000)
IZII (+0.17218,+0.00000)
IZIZ (+0.16893,+0.00000)
IZZI (+0.16615,+0.00000)
ZIII (-0.22575,+0.00000)
ZIIZ (+0.16615,+0.00000)
ZIZI (+0.17464,+0.00000)
ZZII (+0.12091,+0.00000)
XXXX (+0.04523,+0.00000)
XXYY (+0.04523,+0.00000)
YYXX (+0.04523,+0.00000)
YYYY (+0.04523,+0.00000)

## You have completed your first mapping of H2!
What now? The next step is to use this mapping to evaluate the Hamiltonian on a quantum computer. This is the topic of activity 3.2.

## Bonus
In the process we built the `to_matrix()` method. Applying it to the `htot_lcps` will give you the matrix representation of the whole molecular Hamiltonian. You can find the exact solution to this Hamiltonian by diagonalizing this matrix. The lowest eigenvalue and its associated eigenvector are the ground state energy/state vector.

Now you could use a `for` loop over an array of distances (say from 0.3 A to 2.5 A) to successively build H2 molecules, map each one to a `LCPS` and finally to a matrix to find its ground state. You could plot the dissociation curve of the H2 molecule. Just don't forget to add the nucleus-nucleus energy that you can get from `mol.energy_nuc()`.

Notebook by **Maxime Dion** <maxime.dion@usherbrooke.ca>

For the QSciTech-QuantumBC virtual workshop on gate-based quantum computing

```python
%autoreload
n = 50
distances = np.linspace(0.3, 2.5, n)
gs_energies = np.zeros(n)

# Jordan-Wigner Mapping of 4 states to 4 qubits
aps, ams = mapping.fermionic_operator_linear_combinaison_pauli_string(4)

for i, distance in enumerate(distances): #units in AA
    print('Trying Distance '+str(i+1), end="\r")
    mol = gto.M(
        atom = [['H', (0,0,-distance/2)], ['H', (0,0,distance/2)]],
        basis = 'sto-3g'
        )

    # build the molecular Hamiltonian
    molecular_hamiltonian_no_spin = MolecularFermionicHamiltonian.from_pyscf_mol(mol)
    molecular_hamiltonian = molecular_hamiltonian_no_spin.include_spin()

    # map the Hamiltonian to a LCPS
    htot_lcps = molecular_hamiltonian.to_linear_combinaison_pauli_string(aps, ams)
    htot_lcps = htot_lcps.combine().apply_threshold().sort()

    # diagonalize the Hamiltonian to get energies
    Eh2, _ = np.linalg.eigh(htot_lcps.to_matrix())

    gs_energies[i] = Eh2[0] + mol.energy_nuc()
print("Done!                   ", end="\r")
```

    Done!

```python
# plot dissociation curve of H2
fig, ax = plt.subplots(1, 1, figsize=(8,8))
ax.plot(distances, gs_energies, 'r')
ax.set_xlabel(r'Internuclear Distance / $\AA$', fontsize=20)
ax.set_ylabel('Energy / $E_h$', fontsize=20)
ax.set_title('Dissociation Curve of H2', fontsize=28)
# fig.savefig('H2_classical.png')
plt.show()
```

### Try the Same with LiH

```python
%autoreload
n = 30
distances = np.linspace(0.5, 4, n)
gs_energies = np.zeros(n)

# Just ignore the p orbitals for now to see if it will run
small_basis = gto.basis.parse('''
Li    S
      0.3683820000E+02       0.6966866381E-01
      0.5481720000E+01       0.3813463493E+00
      0.1113270000E+01       0.6817026244E+00
Li    S
      0.5402050000E+00      -0.2631264058E+00
      0.1022550000E+00       0.1143387418E+01
''')

# Jordan-Wigner Mapping of 6 states to 6 qubits
aps, ams = mapping.fermionic_operator_linear_combinaison_pauli_string(6)

for i, distance in enumerate(distances): #units in AA
    print('Trying Distance '+str(i+1), end="\r")
    mol = gto.M(
        atom = [['H', (0,0,-distance/2)], ['Li', (0,0,distance/2)]],
        basis = {'H': 'sto-3g', 'Li': small_basis}
        )

    # build the molecular Hamiltonian
    molecular_hamiltonian_no_spin = MolecularFermionicHamiltonian.from_pyscf_mol(mol)
    molecular_hamiltonian = molecular_hamiltonian_no_spin.include_spin()

    # map the Hamiltonian to a LCPS
    htot_lcps = molecular_hamiltonian.to_linear_combinaison_pauli_string(aps, ams)
    htot_lcps = htot_lcps.combine().apply_threshold().sort()

    # diagonalize the Hamiltonian to get energies
    Eh2, _ = np.linalg.eigh(htot_lcps.to_matrix())

    gs_energies[i] = Eh2[0] + mol.energy_nuc()
print("Done!                   ")
```

    Done!

```python
# plot dissociation curve of LiH
fig, ax = plt.subplots(1, 1, figsize=(8,8))
ax.plot(distances, gs_energies, 'r')
ax.set_xlabel(r'Internuclear Distance / $\AA$', fontsize=20)
ax.set_ylabel('Energy / $E_h$', fontsize=20)
ax.set_title('Dissociation Curve of LiH', fontsize=28)
# fig.savefig('LiH_classical.png')
plt.show()
```

```python

```


### Helpful resources
1. https://christian-igel.github.io/paper/TRBMAI.pdf
2. https://www.cs.toronto.edu/~hinton/absps/guideTR.pdf

### Probablisitc model
A restricted Boltzmann machine is a special form of a Boltzmann machine. The nodes build a bipartite graph with one set being observable and the other one being hidden.
#### Model assumption
The probability distribution is modeled as
\begin{align}
p(v,h) = \frac{e^{-E(v,h)}}{Z},
\end{align}
where $E$ is the energy function
\begin{align}
E(v,h) = - \sum_{i,j} w_{ij} v_i h_j - \sum_i a_i v_i - \sum_j b_j h_j,
\end{align}
where v, h are the observable and hidden units, respectively; a, b, and w are weights associated with the model.

### Learning a restricted Boltzmann machine
In order to perform maximum likelihood training, we need the marginal distribution over the obvservable units
\begin{align}
    p(v) = \sum_h p(v,h).
\end{align}
The derivative of the log-likelhood (for one data point) with respect to an arbitraty weight $\theta$ is
\begin{align}
    \frac{\partial \log \mathcal{L}(\theta \mid v)}{\partial \theta} &= \frac{\partial}{\partial \theta} \left[ - \log Z + \log \left( \sum_h  e^{-E(v,h)} \right) \right] \\
    &= - \frac{1}{ \sum_h  e^{-E(v,h)}}  \sum_h  e^{-E(v,h)} \frac{\partial E(v,h)}{\partial \theta} + \frac{1}{ \sum_{v,h}  e^{-E(v,h)}}  \sum_{v,h}  e^{-E(v,h)} \frac{\partial E(v,h)}{\partial \theta} \\
    &= - \underbrace{\sum_h p(h \mid v) \frac{\partial E(v,h)}{\partial \theta}}_{\text{positive phase}} + \underbrace{\sum_{v,h} p(v,h) \frac{\partial E(v,h)}{\partial \theta}}_{\text{negative phase}}.
\end{align}
#### Conditional distributions
Due to the assumption of the nodes being in a bipartite graph, we can easily caluclate the conditional distributions as
\begin{align}
    p(h_j=1 \mid v) = \sigma \left( \sum_{i=1}^{n_{\text{visible}}} w_{ij} v_i + b_j \right),
\end{align}
and
\begin{align}
p(v_i=1 \mid h) = \sigma \left( \sum_{j=1}^{n_{\text{hidden}}} w_{ij} h_j + a_i \right).
\end{align}
#### Factorization trick
The positive phase can be cheaply calculated when using the factorization trick, e.g., for $\theta = w_{ij}$ we get
\begin{align}
    - \sum_h p(h \mid v) \frac{\partial E(v,h)}{\partial w_{ij}} &= \sum_h \prod_{k=1} p(h_k \mid v) v_i h_j \\
    &= \sum_{h_j} \sum_{h_{-j}} p(h_j \mid v) p(h_{-j} \mid v) h_j v_i \\
    &= \sum_{h_j} p(h_j \mid v) h_j v_i \underbrace{\left( \sum_{h_{-j}} p(h_{-j} \mid v) \right)}_{=1} \\
    &= \sum_{h_j} p(h_j \mid v) h_j v_i = p(h_j = 1 \mid v) v_i.
\end{align}
#### Learning algorithm
Using the factorizatin trick, the derivatives of the log-likelihood (for onde data point) with respect to the connection weights $w_{ij}$, visible biases $a_i$ and hidden biases $b_j$ can be computed as
\begin{align}
     \frac{\partial \log \mathcal{L}(\theta \mid v)}{\partial w_{ij}} &= p(h_j = 1 \mid v) v_i - \sum_v p(v) p(h_j = 1 \mid v) v_i, \\
    \frac{\partial \log \mathcal{L}(\theta \mid v)}{\partial a_i} &= v_i - \sum_v p(v) v_i, \\
    \frac{\partial \log \mathcal{L}(\theta \mid v)}{\partial b_i} &= p(h_j=1 \mid v) - \sum_v p(v) p(h_j = 1 \mid v),
\end{align}
respectively. For a large number of visible units, the sum is intractable as the summands increase exponentially, hence we need to approximate all gradients.
#### Contrastive divergence
We could approximate the second terms of the three equations above using block Gibbs sampling, however, this would require to run the Markov chain for a long time. It has been shown that estimates obtained after the chain for just a few steps sufficies for model training. This idea has led to the $k$-step constrastive divergence algorithm. We basically set $v^{(0)} = v$, where $v$ is a training example and sample $h^{(0)}$ from $p\left(h \mid v^{(0)} \right)$. In the next step, $v^{(1)}$ is then sampled from $p \left( v \mid h^{(0)} \right)$ and so on.

```python
import numpy as np
```

```python
class RestrictedBoltzmannMachine:
    def __init__(self, init_weights, init_visible_biases, init_hidden_biases):
        self.weights = init_weights
        self.visible_biases = init_visible_biases
        self.hidden_biases = init_hidden_biases

    @staticmethod
    def _logistic_function(x):
        return (1 / (1 + np.exp(-x)))

    def _energy_function(self, v, h):
        energy = 0

        for i in range(self.weights.shape[0]):
            for j in range(self.weights.shape[1]):
                energy += - self.weights[i, j] * v[i] * h[j]

        for i, visible_bias in enumerate(self.visible_biases):
            energy += - visible_bias * v[i]

        for j, hidden_bias in enumerate(self.hidden_biases):
            energy += - hidden_bias * h[j]

        return energy

    def _conditional_h_given_v(self, v):
        return self._logistic_function((self.weights.T @ v) + self.hidden_biases)

    def _conditional_v_given_h(self, h):
        return self._logistic_function((self.weights @ h) + self.visible_biases)

    @staticmethod
    def _probability_to_logit(x):
        for i in range(x.shape[0]):
            u = np.random.uniform(0, 1)
            if x[i] > u:
                x[i] = 1.0
            else:
                x[i] = 0.0

        return x

    def _sampling_k_steps(self, v, k):
        for _ in range(k):
            h = self._probability_to_logit(self._conditional_h_given_v(v))
            v = self._probability_to_logit(self._conditional_v_given_h(h))

        return v

    def block_gibbs_sampling(self, n_samples, n_burn_in=1000):
        v = np.zeros((self.visible_biases.shape[0], 1))

        samples = []
        for i in range(n_burn_in + n_samples):
            v = self._sampling_k_steps(v, 1)

            if i >= n_burn_in:
                samples.append(v)

        return samples
```

```python
def k_step_contrastive_divergence(data, model, learning_rate=1.0, k=1):
    for v in data:
        v = v.reshape((model.visible_biases.shape[0], 1))

        sampled_v = model._sampling_k_steps(v, k)

        model.weights += learning_rate * (v @ model._conditional_h_given_v(v).T -
                                          sampled_v @ model._conditional_h_given_v(sampled_v).T)

        model.visible_biases += learning_rate * (v - sampled_v)

        model.hidden_biases += learning_rate * (model._conditional_h_given_v(v) -
                                                model._conditional_h_given_v(sampled_v))
```

```python
import matplotlib.pyplot as plt
from itertools import product
%matplotlib inline

n_visible_units = 3
n_hidden_units = 16
n_points = 100
n_steps = 100
p = [.8, .15, .05]

weights = np.random.normal(size=(n_visible_units, n_hidden_units))
visible_biases = np.random.normal(size=(n_visible_units, 1))
hidden_biases = np.random.normal(size=(n_hidden_units, 1))

rbm = RestrictedBoltzmannMachine(weights, visible_biases, hidden_biases)
```

```python
### [[0, 0, 0], [0, 0, 1], [0, 1, 0], ..., [1, 1, 0], [1, 1, 1]]
all_configs = list(product([0, 1], repeat=n_visible_units))

data =  np.random.binomial(n=1, p=p, size=(n_points, n_visible_units))

data_distribution = np.zeros((len(all_configs),))

for x in data:
    for j, config in enumerate(all_configs):
        if np.sum(x == config) == n_visible_units:
            data_distribution[j] += 1 / n_points
```

```python
for i in range(n_steps):
    k_step_contrastive_divergence(data, rbm, learning_rate=0.1, k=1)
```

```python
n_sampling = 1000
samples = rbm.block_gibbs_sampling(n_sampling, n_burn_in=1000)
```

```python
sample_distribution = np.zeros((len(all_configs),))

for sample in samples:
    for j, config in enumerate(all_configs):
        if np.sum(sample.T == config) == n_visible_units:
            sample_distribution[j] += 1 / n_sampling
```

```python
for i, _ in enumerate(all_configs):
    print('Configuration:', all_configs[i],
          'learned distribution: {0:.3f}'.format(sample_distribution[i]),
          'data distribution: {0:.3f}'.format(data_distribution[i]))
```

    Configuration: (0, 0, 0) learned distribution: 0.018 data distribution: 0.130
    Configuration: (0, 0, 1) learned distribution: 0.001 data distribution: 0.000
    Configuration: (0, 1, 0) learned distribution: 0.013 data distribution: 0.040
    Configuration: (0, 1, 1) learned distribution: 0.009 data distribution: 0.010
    Configuration: (1, 0, 0) learned distribution: 0.769 data distribution: 0.690
    Configuration: (1, 0, 1) learned distribution: 0.008 data distribution: 0.010
    Configuration: (1, 1, 0) learned distribution: 0.171 data distribution: 0.120
    Configuration: (1, 1, 1) learned distribution: 0.011 data distribution: 0.000


# Modélisation de la prise en glace du stockage

En hiver, les déperditions à travers les parois du stockage et l'augmentation de la consommation énergétique de l'habitat provoquent une chute de la température du stockage.
La simualtion de nos premiers modèles nous révèle que la température du stockage peut chuter en desous de 0°C et donc l'apparition du gel.
Nous procédons dans cette partie à la prise en compte de l'apparition de ce gel.

Soit la figure suivante montrant le phénomène de prise en glace

## Mise en équations

On note:

- $R_g$ le rayon de la glace se formant autour du tube géothermique en (m);

- $\rho_{g}$ la masse volumique de la glace en (kg/$m^3$);

- $\Delta h_{Lg} $ la chaleur latente de fusion de la glace en (kJ/kg);

- $ T_f$ la température du fluide (eau ou eau glycolée) en (°C);

- $ \dot m_f $ le débit masique du fluide en (kg/s);

- $ R_e$ le rayon extérieur du tube en (m);

- $ R_i$ le rayon intérieur du tube en (m);

- $ \lambda $  la conductivité thermique en (W/m.K);

- $ dx $ le pas d'espace en (m)

- $tu$,$g$ et $f$ indices désigant respectivement le tube, la glace et le fluide.

Le flux de chaleur échangée entre le fluide et le stockage s'exprime par (1):

\begin{equation}
\frac {dQ}{dx}= \dot m_f C_{pf} \frac {dT_f}{dx} = \frac {T_f -0}{\ln \frac {R_g}{R_e}}*2 \pi \lambda
\end{equation}

Ce même flux est celui qui est reçu par la glace (2):

\begin{equation}
\frac {dQ}{dx}= \phi 2 \pi R_g dR_g \rho_g \Delta h_{Lg}
\end{equation}

En abscence de glace, nous avons la figure suivante:

Le flux de chaleur dans ce cas est (3) :

\begin{equation}
\frac {dQ}{dx}=2 \pi \frac{T_f -T_{sable}}{\frac{1}{h_i R_i} + \frac{ \ln \frac{R_e}{R_i}}{\lambda_{tu}}+\frac{\ln \frac{R_{sable}}{R_e}}{\lambda_{sable}} }
\end{equation}

## Résolution numérique

Soit j, l'indice de l'espace
De l'équation (1), en utilisant un discétisaton suivant la méthode de Euler explicite , on a :

\begin{equation}
 \frac{Tf^n_{j+1}-Tf^n_{j}}{\Delta x}=\frac {Tf^n_j}{\ln \frac {R^n_g}{R_e}}*2 \pi \lambda
\end{equation}

soit :
\begin{equation}
 Tf^n_{j+1}=Tf^n_{j}+\Delta x \frac {Tf^n_j}{\ln \frac {R^n_g}{R_e}}*2 \pi \lambda
\end{equation}

De l'équation (2) , on obtient :

\begin{equation}
\frac{Q^n_{j+1}-Q^n_j}{\Delta x}= \phi 2 \pi Rg^n_j (Rg^n_{j+1}-Rg^n_j) \rho_g \Delta h_{Lg}
\end{equation}