#
 Variational Quantum Linear Solver on Rigetti QCS

 Ryan LaRose, Department of Computational Mathematics, Science, and Engineering & Department of Physics and Astronomy, Michigan State University.

###
 Abstract

 The variational quantum linear solver (VQLS) [1] is an algorithm for solving linear systems on near-term quantum computers. While VQLS does not offer performance guaruntees like other quantum linear systems algorithms [2-3], the ability to run on near-term computers makes it an interesting problem-specific benchmark. In this notebook, we provide a brief tutorial of VQLS and show results of running VQLS on Rigetti QCS for example linear systems.

##
 Notebook Setup

 We use pyQuil to write the VQLS algorithm and send job submissions to Rigetti QCS. In the notebook, we use the Rigetti QVM for tutorial purposes. When running on the device, the code written in the notebook is converted to a script and executed on the QPU.

```python
"""Imports."""
from itertools import product
from math import pi
import time

import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from scipy.optimize import (dual_annealing, fmin, fmin_powell, fmin_cobyla, minimize)

import pyquil
from pyquil import Program, get_qc
import pyquil.gates as gates

from aqgd import AQGD
import vqls
```

```python
"""Constants and other setup."""
np.set_printoptions(precision=3)
%matplotlib inline
```

#
 The VQLS Algorithm

A linear system $A \mathbf{x} = \mathbf{b}$ is defined by a matrix $A \in \mathbb{C}^{N \times N}$ and vector $\mathbf{b} \in \mathbb{C}^{N}$. Given $A$ and $\mathbf{b}$ as input, the goal is to output $\mathbf{x} \in \mathbb{C}^{N}$, or an approximate solution $\mathbf{\bar{x}}$ where $|| \mathbf{\bar{x}} - \mathbf{x} || \le \delta$.

###
 Problem Setup

 How can we solve linear systems of equations with a (near-term) quantum computer? Without loss of generality, we can express the matrix $A$ is a linear combination of Paulis

\begin{equation}
    A = \sum_{l = 1}^{L} a_l A_l .
\end{equation}

 The vector $|\mathbf{b}\rangle$ is assumed to have some unitary $B$ which prepares it from the ground state, i.e. $B|0\rangle = |\mathbf{b}\rangle$. We can also take $B = \sum_{m = 1}^{M} b_m B_m$ as a linear combination of Paulis.

We want to prepare a state

\begin{equation}
    |\mathbf{\theta}\rangle := V(\mathbf{\theta}) |0\rangle
\end{equation}

 which has high fidelity $| \langle \theta | \mathbf{x} \rangle |^2$ with the solution $|x\rangle$ of the linear system $A |\mathbf{x}\rangle = |\mathbf{b}\rangle $. Equivalenlty, we want a state $|\theta\rangle$ such that $A |\theta\rangle$ is close to $|\mathbf{b}\rangle$. By maximizing the fidelity $|\langle \mathbf{b} | A |\theta\rangle|^2$ where

\begin{equation}
    \langle \mathbf{b} | A |\theta\rangle = \langle 0 | B^\dagger A V(\theta) |0\rangle = \sum_{l = 1}^{L} c_l \langle 0 | B^\dagger A_l V(\theta) |0\rangle .
\end{equation}

one can obtain an approximate solution $|\theta_\text{opt}\rangle = V(\theta_\text{opt}) |0\rangle$ to the linear system.

One way to compute the fidelity is via the Hadamard Test or extensions of the Hadamard Test [1]. Another way is described below.

###
 The Effective Hamiltonian Approach

An interesting connection to other linear systems work [4] and variational algorithms literature [5] is to note that the above prescription is equivalent to minimizing the energy of an effective Hamiltonian

\begin{equation}
    H_{A, \mathbf{b}} \equiv H := A^\dagger ( I - |\mathbf{b}\rangle \langle \mathbf{b} | ) A
\end{equation}

This can be seen by noting that $H$ is positive semidefinite and $H |\mathbf{x}\rangle = 0$. Thus, by minimizing $\langle \theta | H |\theta \rangle$, we can get an approximate solution to the linear system. A key advantage of this approach is the ability to compute expectation values in a hardware efficient manner. (That is, not using controlled gates required by the Hadamard test but instead using classical post-processing [5-7].) We use this effective Hamiltonian approach in our simulations below.

Noting that $H = A^\dagger A - A^\dagger P_{\mathbf{b}} A$ where $P_{\mathbf{b}} := |\mathbf{b}\rangle \langle \mathbf{b} |$ is the projector onto the $|\mathbf{b}\rangle$ state, we have two terms to evaluate. For the first, one can show that

\begin{equation}
    A^\dagger A = \sum_{l = 1}^{L} |a_l|^2 I + 2 \sum_{1 = l < k}^{L - 1} \text{Re} \, [a_l^* a_k ] A_l A_k .
\end{equation}

The expectation of the first summation can be computed classically, while the second requires $L (L - 1) / 2 = O(L^2)$ circuit evaluations.

For the second term in the Hamiltonian $H$, one can see that

\begin{equation}
    A^\dagger P_{\mathbf{b}} A = \sum_{k = 1}^{L} \sum_{l = 1}^{L} a_k^* a_l A_k P_\mathbf{b} A_l .
\end{equation}

Assuming $B = \sum_{m = 1}^{M} b_m B_m$, the projector $P_\mathbf{b} = B|0\rangle \langle 0 | B^\dagger$ can be expanded as a linear combination of unitaries as well. Note that $|0\rangle\langle0| = (I + Z) / 2$. In general, there are $N M^2 L^2$ weighted Pauli operators in this term ($N = 2^n$ where $n$ is the number of qubits).

As mentioned, the advantage of this approach is the short-depth circuits for cost evaluation. The disadvantage of this approach is that it can lead to many terms in the Pauli expansion of $H_{A, \mathbf{b}}$. An interesting observation is the "physicality" of this effective Hamiltonian -- namely, physical Hamiltonians usually have (or are assumed to have) poly($n$) terms, while this Hamiltonian has $O(N)$ terms in the worst case. We remark that simplifications which reduce the number of terms in the effective Hamiltonian $H_{A, \textbf{b}}$ are usually possible. Additionally, computing $\langle H \rangle$ by exploiting simulataneous measurements of observables can mitigate the large number of terms. Finally, we remark that coefficients in the projector $|0\rangle \langle 0|$ on $n$ qubits are equal to $1 / 2^n$, and it can be a reasonable approximation to skip terms with small coefficients.

#
 Linear System 1

 The first linear system we consider is an example from the VQLS paper. Our motivation for this is to compare results from the paper (which used older Rigetti computers) to results we get on the new computers. The linear system is a three- to five-qubit example given by

\begin{equation}
    A_1 = I + 0.2 X_1 + 0.2 X_1 Z_2 .
\end{equation}

and $\mathbf{b}_1 = H_1 H_2 H_4 H_4 |0\rangle^{\otimes n}$. Here, $I, X, Y$ and $Z$ are the usual Pauli operators

\begin{equation}
    I := \left[ \begin{matrix}
    1 & 0 \\
    0 & 1 \\
    \end{matrix} \right], \qquad
    X := \left[ \begin{matrix}
    0 & 1 \\
    1 & 0 \\
    \end{matrix} \right], \qquad
    Y := \left[ \begin{matrix}
    0 & -i \\
    i & 0 \\
    \end{matrix} \right], \qquad
    Z := \left[ \begin{matrix}
    1 & 0 \\
    0 & -1 \\
    \end{matrix} \right]
\end{equation}

and subscripts indicate which qubits the Paulis act on. For example, $X_1$ means $X$ on the first qubit, i.e. $XII$ on three qubits or $X I I I I$ on five qubits.

For concreteness, we can reduce to three qubits and view the $8 \times 8$ matrix of this linear system. (Of course this works for any number of qubits, but we quickly run out of room on the screen!)

```python
"""View the matrix of a linear system."""
# Define the matrix of the linear system as weighted Paulis
Acoeffs = [1., 0.2, 0.2]
Aterms = ["III", "XII", "XZI"]

# Display the matrix
print("The matrix of the linear system is:")
print(np.real(vqls.matrix(Acoeffs, Aterms)))
```

    The matrix of the linear system is:
    [[1.  0.  0.  0.  0.4 0.  0.  0. ]
     [0.  1.  0.  0.  0.  0.4 0.  0. ]
     [0.  0.  1.  0.  0.  0.  0.  0. ]
     [0.  0.  0.  1.  0.  0.  0.  0. ]
     [0.4 0.  0.  0.  1.  0.  0.  0. ]
     [0.  0.4 0.  0.  0.  1.  0.  0. ]
     [0.  0.  0.  0.  0.  0.  1.  0. ]
     [0.  0.  0.  0.  0.  0.  0.  1. ]]

Note: The matrix is quite sparse. This is expected for linear systems with only a few weighted Paulis, as Pauli strings are sparse matrices.

Using the identity $H = (I + Z) / \sqrt{2}$, we can expand the $B$ unitary as

\begin{align}
    B &= \frac{1}{2^2} (I + Z) \otimes (I + Z) \otimes I \otimes (I + Z) \otimes (I + Z) \\
      &= \frac{1}{2^2} \left[ IIIII + IIIIZ + IIIZI + IIIZZ + IZIII + IZIIZ + IZIZI + IZIZZ + \cdots \right]
\end{align}

##
 Three Qubit Tutorial

Below we consider the three-qubit verstion of the linear system above. First, we define the linear system in terms of $A$ and $B$, then compute the effective Hamiltonian of the linear system.

```python
"""Terms in effective Hamiltonian for first linear system."""
# Number of qubits
n = 3

# Define the B matrix of the linear system
Bcoeffs = [1 / 2**(n / 2)] * 2**n
paulis = [["X", "Z"]] * n
prods = list(product(*paulis))
Bterms = ["".join(p) for p in prods]

# Define the A matrix of the linear system
Acoeffs = [1, 0.2, 0.2]
Aterms = ["III", "XII", "XZI"]

# Get the effective Hamiltonian
ham = vqls.effective_hamiltonian(Acoeffs, Aterms, Bcoeffs, Bterms)

# Display the effective Hamiltonian
nterms = len(ham)
print("Number of terms:", nterms)
print("H = ")
for ii in range(nterms):
    ham[ii][0] = np.real(ham[ii][0])
    print(f"{round(ham[ii][0], 3)} \t* \t{ham[ii][1]}", (lambda ii: "+" if ii < nterms - 1 else "")(ii))
```

    Number of terms: 12
    H =
    0.895 	* 	III +
    0.215 	* 	XII +
    0.34 	* 	XZI +
    0.02 	* 	IZI +
    -0.185 	* 	IIX +
    -0.175 	* 	IXI +
    -0.06 	* 	IZX +
    -0.175 	* 	IXX +
    -0.185 	* 	XIX +
    -0.175 	* 	XXI +
    -0.06 	* 	XZX +
    -0.175 	* 	XXX

To evaluate these terms with a quantum computer, we first need a quantum computer! Below we select an appropriate `Aspen` lattice.

```python
"""Get a quantum computer to run on."""
qcomputer = f"Aspen-7-{n}Q-B"  # Three qubit lattice for testing optimizer
lattice = get_qc(qcomputer, as_qvm=True)  # Change to as_qvm=False to run on QC. Must have reservation.
```

We can visualize the connectivity of this lattice by drawing the qubit topology.

```python
"""Visualize the qubit connectivity."""
graph = lattice.qubit_topology()
nx.draw(graph)
```

###
 Variational Ansatz

The next and final ingredient we need to compute $\langle \theta | H | \theta \rangle$ is an ansatz state $|\theta\rangle$. We take this to be a product of $Y$-rotations on each qubit in the computer, obtained via `vqls.yansatz`.

```python
"""Get an ansatz and visualize it."""
circ, creg = vqls.yansatz(lattice)
print(circ)
```

As we see, the main circuit is simply $Y$-rotations on each qubit where the parameters `theta[0], theta[1], theta[2]` are independent.

The following function defines the cost we want to minimize by varying the parameters in the ansatz. The cost is exactly the energy of the effective Hamiltonian $H_{A, \mathbf{b}}$ computed via `vqls.energy`.

```python
# TODO: Figure out why expectation value with type(angles) == np.array explodes (report bug)
# angles = np.array([1, 2, 3])
# angles = list(angles)
# vqls.expectation(angles, ham[1][0], ham[1][1], circ, creg, lattice, verbose=True)
```

```python
"""Cost function for linear system 1."""
def costLS1(angles, verbose: bool = False):
    val = vqls.energy(angles, ham, circ, creg, lattice, shots=10000)
    if verbose:
        print("Current angles:", angles)
        print("Current energy:", val)
    return val
```

```python
"""Optimize by gradient descent."""
optimizer = AQGD(maxiter=25, tol=1e-3, eta=2.0, momentum=0.25, disp=True)
start = time.time()
res = optimizer.optimize([0] * n, costLS1)
print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
"""Optimization: Testing fmin functions from scipy.optimize."""
start = time.time()
res = fmin(costLS1, x0=[pi / 2] * n, ftol=0.001, maxfun=50, args=(True,))
print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
"""Do the optimization."""
# start = time.time()
# res = minimize(costLS1, x0=[pi / 2] * n, method="COBYLA", args=(True,))
# print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
"""Optimize by annealing."""
# start = time.time()
# res = dual_annealing(costLS1, bounds=[(0, 2 * pi)] * n, args=(True,))
# print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
"""Compute energy at optimal angles."""
vqls.energy([1.61, 1.88, 1.61], ham, circ, creg, lattice)
```

###
 Compare the Quantum and Classical Solutions

```python
print("Minimum cost found:", res.fun)
print("Best found angles:", res.x)
```

```python
qxvec = vqls.qsolution(circ, res)
print(qxvec)
```

```python
"""Compare to classical solution."""
# Get the classical solution
Amat = vqls.matrix(Acoeffs, Aterms)
bvec = vqls.vector(Bcoeffs, Bterms)
xvec = np.linalg.solve(Amat, bvec)
xvec /= np.linalg.norm(xvec)

# ===========================
# Compare classical x vectors
# ===========================

print("Comparing x vectors:")
print("====================")

# Compute the fidelity
fidelity = abs(np.dot(qxvec.conj(), xvec))**2
print(r"| < xquantum | xclassical > |^2 =", round(fidelity, 2))

# Compute the L2 distance
l2dist = np.linalg.norm(qxvec - xvec, ord=2)**2
print(r"|| xquantum - xclassical ||^2 =", round(l2dist, 2))

# ==========================
# Compare solution b vectors
# ==========================

print("\nComparing b vectors:")
print("====================")

# Compute |qbvec> = A |theta optimal>
qbvec = Amat @ qxvec
qbvec /= np.linalg.norm(qbvec)
bfidelity = abs(np.dot(bvec.conj(), qbvec))**2
print(r"| < bquantum | bclassical > |^2 =", round(bfidelity, 3))

bl2dist = np.linalg.norm(qbvec - bvec, ord=2)**2
print(r"|| bquantum - bclassical ||^2 =", round(bl2dist, 3))
```

**SAVE THIS CELL!**

```python
"""SAVE THIS CELL!

Optimal angles for Ry product ansatz. Energy of effective Hamiltonian is near 0.001 here.
"""
LS1opt_angles = [1.60380032, 1.88421818, 1.61131353, 1.51679728, 1.44425814]
```

## Simultaneous measurements

```python
"""Compare runtime for individual expectations vs simultaneous measurements."""
# Define number of qubits
n = 10

# Get a group to measure
group = [(1, "IIIIZIIIIZ"), (1, "IIIIIIZZII"), (1, "ZZZZZZZZZI"),
         (1, "IIIZZZIIIZ"), (1, "ZZIZZIZZZZ"), (1, "ZZZIZZZIZZ"),
         (1, "IIZZZIZZIZ"), (1, "ZZIZIIZIIZ"), (1, "ZZZZZZZZIZ"),
         (1, "ZZZIIIIIIZ"), (1, "IIIZIIIIZI"), (1, "ZIZZIIZZIZ")]

# Get a quantum computer to run on
qcomputer = f"Aspen-7-{n}Q-B"
lattice = get_qc(qcomputer, as_qvm=True)  # Change to as_qvm=False to run on QC. Must have reservation.

# Get an ansatz and set the angles
circ, creg = vqls.yansatz(lattice)
angles = [0] * n

print(circ)
```

    DECLARE ro BIT[10]
    DECLARE theta REAL[10]
    RY(theta[0]) 20
    RY(theta[1]) 21
    RY(theta[2]) 27
    RY(theta[3]) 30
    RY(theta[4]) 31
    RY(theta[5]) 32
    RY(theta[6]) 33
    RY(theta[7]) 35
    RY(theta[8]) 36
    RY(theta[9]) 37

```python
"""Measure each expectation value individually."""
start = time.time()
tot = 0.
for coeff, pauli in group:
    tot += vqls.expectation(angles, coeff, pauli, circ, creg, lattice, shots=10_000)
print(tot)
print("Runtime:", round(time.time() - start, 3), "seconds.")
```

    12.0
    Runtime: 40.864 seconds.

```python
"""Measure the group."""
start = time.time()
gtot = vqls.measure_group(angles, group, circ, creg, lattice, shots=10_000)
print(gtot)
print("Runtime:", round(time.time() - start, 3), "seconds.")
```

    12.0
    Runtime: 4.156 seconds.

##
 Five Qubit Example

```python
"""Terms in effective Hamiltonian for first linear system."""
# Number of qubits
n = 5

# Define the B matrix of the linear system
Bcoeffs = [1 / 2**(n / 2)] * 2**n
paulis = [["X", "Z"]] * n
prods = list(product(*paulis))
Bterms = ["".join(p) for p in prods]

# Define the A matrix of the linear system
Acoeffs = [1, 0.2, 0.2]
Aterms = ["IIIII", "XIIII", "XZIII"]

# Get the effective Hamiltonian
ham = vqls.effective_hamiltonian(Acoeffs, Aterms, Bcoeffs, Bterms)

# Display the effective Hamiltonian
nterms = len(ham)
print("Number of terms:", nterms)
print("H = ")
for ii in range(nterms):
    ham[ii][0] = np.real(ham[ii][0])
    print(f"{round(ham[ii][0], 3)} \t* \t{ham[ii][1]}", (lambda ii: "+" if ii < nterms - 1 else "")(ii))
```

```python
"""Group Hamiltonian into simultaneously measurable sets of operators."""
ham = vqls.group_greedy(ham)
print("# groups =", len(ham))

# Consistency check
for group in ham:
    _, paulis = vqls.split_ham_to_coeffs_and_paulis(group)
    assert vqls.is_sim_meas_group(paulis)
```

```python
"""Get a quantum computer to run on."""
qcomputer = f"Aspen-7-{n}Q-B"  # Three qubit lattice for testing optimizer
lattice = get_qc(qcomputer, as_qvm=True)  # Change to as_qvm=False to run on QC. Must have reservation.
```

```python
"""Visualize the qubit connectivity."""
graph = lattice.qubit_topology()
nx.draw(graph)
```

```python
"""Get an ansatz and visualize it."""
circ, creg = vqls.yansatzCZ(lattice)
print(circ)
```

```python

```

```python
"""Cost function."""
def cost(angles, verbose: bool = False):
    val = vqls.energy_sim(angles, ham, circ, creg, lattice, shots=10_000)
    if verbose:
        print("Current angles:", angles)
        print("Current energy:", val)
    return val
```

```python
"""Do the optimization."""
start = time.time()
res = minimize(cost, x0=[pi / 2] * 3 * n, method="Powell", args=(True,))
print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
print("Minimum cost found:", cost(res))
print("Best found angles:", res)
```

```python
qxvec = vqls.qsolution(circ, res)
print(qxvec)
```

```python
"""Compare to classical solution."""
# Get the classical solution
Amat = vqls.matrix(Acoeffs, Aterms)
bvec = vqls.vector(Bcoeffs, Bterms)
xvec = np.linalg.solve(Amat, bvec)
xvec /= np.linalg.norm(xvec)

# ===========================
# Compare classical x vectors
# ===========================

print("Comparing x vectors:")
print("====================")

# Compute the fidelity
fidelity = abs(np.dot(qxvec.conj(), xvec))**2
print(r"| < xquantum | xclassical > |^2 =", round(fidelity, 2))

# Compute the L2 distance
l2dist = np.linalg.norm(qxvec - xvec, ord=2)**2
print(r"|| xquantum - xclassical ||^2 =", round(l2dist, 2))

# ==========================
# Compare solution b vectors
# ==========================

print("\nComparing b vectors:")
print("====================")

# Compute |qbvec> = A |theta optimal>
qbvec = Amat @ qxvec
qbvec /= np.linalg.norm(qbvec)
bfidelity = abs(np.dot(bvec.conj(), qbvec))**2
print(r"| < bquantum | bclassical > |^2 =", round(bfidelity, 2))

bl2dist = np.linalg.norm(qbvec - bvec, ord=2)**2
print(r"|| bquantum - bclassical ||^2 =", round(bl2dist, 2))
```

#
 Linear System 2

The second linear system we consider is formed from the Ising model.

\begin{equation}
    A_2 := \frac{1}{\zeta} \left(\eta I + \sum_{j=1}^{n} X_j + J \sum_{j=1}^{n-1}Z_jZ_{j+1} \right)
\end{equation}

```python
def get_Acoeffs_Aterms_LS2(n: int, zeta: float, eta: float, J: float):
    """Returns linear system 2 expressed as weighted Pauli sum."""
    if n < 0:
        raise ValueError("Number of qubits must be >= 1.")
    # Add the identity
    Acoeffs = [eta / zeta]
    Aterms = ["I" * n]

    # Add the X terms
    Acoeffs += [1 / zeta] * n
    xbase = "X" + "I" * (n - 1)
    for ii in range(n, 0, -1):
        Aterms.append(xbase[ii:] + xbase[:ii])

    # Add the ZZ terms
    Acoeffs += [J / zeta] * (n - 1)
    zzbase = "ZZ" + "I" * (n - 2)
    for ii in range(n, 1, -1):
        Aterms.append(zzbase[ii:] + zzbase[:ii])

    return Acoeffs, Aterms
```

```python
"""Unit tests for getting LS2."""
Acoeffs, Aterms = get_Acoeffs_Aterms_LS2(4, 1, 1, 0.1)
assert Acoeffs == [1.0, 1.0, 1.0, 1.0, 1.0, 0.1, 0.1, 0.1]
assert Aterms == ['IIII', 'XIII', 'IXII', 'IIXI', 'IIIX', 'ZZII', 'IZZI', 'IIZZ']
```

```python
"""Terms in effective Hamiltonian for second linear system."""
# Number of qubits
n = 3

# Constants
zeta = 1.0
J = 0.1
eta = 1.0

# |b> = H|0>
# Bcoeffs = [1 / 2**(n / 2)] * 2**n
# paulis = [["X", "Z"]] * n
# prods = list(product(*paulis))
# Bterms = ["".join(p) for p in prods]

# |b> = |10...0>
Bcoeffs = [1.]
Bterms = ["I" * n]

# Linear system in weighted Paulis
Acoeffs, Aterms = get_Acoeffs_Aterms_LS2(n, zeta, eta, J)

# Normalize A and B coefficients
# TODO: Do this calculation in terms of the coefficients, not the explicit matrix
Amat = vqls.matrix(Acoeffs, Aterms)
Anorm = np.linalg.norm(Amat, ord=2)
Acoeffs = np.array(Acoeffs)
Acoeffs /= Anorm

Bmat = vqls.matrix(Bcoeffs, Bterms)
Bnorm = np.linalg.norm(Bmat, ord=2)
Bcoeffs = np.array(Bcoeffs)
Bcoeffs /= Bnorm

ham = vqls.effective_hamiltonian(Acoeffs, Aterms, Bcoeffs, Bterms)

# Display the effective Hamiltonian
nterms = len(ham)
num = 0
min_weight = 0.01
print("Number of terms:", nterms)
for ii in range(nterms):
    ham[ii][0] = round(np.real(ham[ii][0]), 3)
    if abs(ham[ii][0]) > min_weight:
        num += 1
#     print(f"{np.real(round(ham[ii][0], 3))} \t* \t{ham[ii][1]}", (lambda ii: "+" if ii < nterms - 1 else "")(ii))

print(f"Number of terms with |coeff| > {min_weight} = {num}")
```

    Number of terms: 32
    Number of terms with |coeff| > 0.01 = 27

```python
"""View matrix of linear system we are solving."""
Amat = vqls.matrix(Acoeffs, Aterms)
print("A =\n", np.real(Amat))
```

    A =
     [[0.3  0.25 0.25 0.   0.25 0.   0.   0.  ]
     [0.25 0.25 0.   0.25 0.   0.25 0.   0.  ]
     [0.25 0.   0.2  0.25 0.   0.   0.25 0.  ]
     [0.   0.25 0.25 0.25 0.   0.   0.   0.25]
     [0.25 0.   0.   0.   0.25 0.25 0.25 0.  ]
     [0.   0.25 0.   0.   0.25 0.2  0.   0.25]
     [0.   0.   0.25 0.   0.25 0.   0.25 0.25]
     [0.   0.   0.   0.25 0.   0.25 0.25 0.3 ]]

```python
"""Drop terms with weight below a given threshold."""
def drop_below(threshold: float):
    ham_thresh = []
    for ii in range(len(ham)):
        if abs(ham[ii][0]) > threshold:
            ham_thresh.append(ham[ii])
    return ham_thresh
```

```python
"""Drop Paulis in Hamiltonian with weight below a given threshold."""
threshold = 0.01
hamt = drop_below(threshold)
print("# terms =", len(hamt))
```

    # terms = 37

```python
"""Group the Hamiltonian into simultaneously measurable sets."""
ham = vqls.group_greedy(ham, randomized=True)
print("# groups =", len(ham))
```

    # groups = 124

```python
hamt = vqls.group_greedy(hamt, randomized=True)
print("# groups =", len(hamt))
```

    # groups = 1

```python
"""Get a computer to run on."""
qcomputer = f"Aspen-7-{n}Q-B"
lattice = get_qc(qcomputer, as_qvm=True)  # Change to as_qvm=False to run on QC. Must have reservation.
```

```python
"""Get an ansatz."""
circ, creg = vqls.yansatz(lattice)
print(circ)
```

    DECLARE ro BIT[8]
    DECLARE theta REAL[8]
    RY(theta[0]) 21
    RY(theta[1]) 30
    RY(theta[2]) 31
    RY(theta[3]) 32
    RY(theta[4]) 33
    RY(theta[5]) 35
    RY(theta[6]) 36
    RY(theta[7]) 37

```python
"""Cost function for linear system 2."""
def cost(angles, verbose: bool = False):
    val = vqls.energy_sim(angles, hamt, circ, creg, lattice, shots=10_000)
    if verbose:
        print("Current angles:", angles)
        print("Current energy:", val)
    return val
```

```python
# Good initial paramaters for the 8 qubit Ising linear system
warm_start = [-1.046,  0.065, -0.842, -1.556, -0.442,  2.197,  0.738, -0.967]
```

```python
"""Do the optimization."""
start = time.time()
res = minimize(cost, x0=np.random.randn(n), method="Powell", args=(True,))
print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
# """Optimize by gradient descent."""
# optimizer = AQGD(maxiter=25, tol=1e-3, eta=5.0, momentum=0.25, disp=True)
# start = time.time()
# res = optimizer.optimize(np.random.randn(n), costLS2)
# print("Runtime:", (time.time() - start) // 60, "minutes.")
```

```python
"""Optimization: Testing fmin functions from scipy.optimize."""
start = time.time()
res = fmin_powell(cost, x0=[pi / 2] * n, maxfun=50, args=(True,))
print("Runtime:", (time.time() - start) // 60, "minutes.")
```

    Current angles: [1.571 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.9750000000000005
    Current angles: [1.571 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.9750000000000005
    Current angles: [2.571 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.8849136000000001
    Current angles: [4.189 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.6162288000000001
    Current angles: [6.807 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.8809968000000002
    Current angles: [4.189 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.6153072000000002
    Current angles: [5.189 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.6134256000000002
    Current angles: [5.807 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.6941808000000006
    Current angles: [4.7   1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.5910000000000002
    Current angles: [4.669 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.5910768000000004
    Current angles: [4.732 1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.5910384000000004
    Current angles: [4.7   1.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.5910000000000002
    Current angles: [4.7   2.571 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.5263152000000001
    Current angles: [4.7   4.189 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.32197920000000013
    Current angles: [4.7   6.807 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.5187216000000004
    Current angles: [4.7   4.189 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.3209712000000001
    Current angles: [4.7   5.189 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.3190992000000003
    Current angles: [4.7   5.807 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.38081760000000014
    Current angles: [4.7   4.704 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.30300000000000005
    Current angles: [4.7   4.672 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.3031728
    Current angles: [4.7   4.735 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.3030288000000001
    Current angles: [4.7   4.704 1.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.30300000000000005
    Current angles: [4.7   4.704 2.571 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.2577648
    Current angles: [4.7   4.704 4.189 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.1246128
    Current angles: [4.7   4.704 6.807 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.2556048000000001
    Current angles: [4.7   4.704 4.189 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.12438240000000002
    Current angles: [4.7   4.704 5.189 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.12154080000000002
    Current angles: [4.7   4.704 5.807 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.16316639999999996
    Current angles: [4.7   4.704 4.722 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.11099999999999999
    Current angles: [4.7   4.704 4.69  1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.11101919999999998
    Current angles: [4.7   4.704 4.753 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.11119199999999999
    Current angles: [4.7   4.704 4.722 1.571 1.571 1.571 1.571 1.571]
    Current energy: 0.11099999999999999
    Current angles: [4.7   4.704 4.722 2.571 1.571 1.571 1.571 1.571]
    Current energy: 0.08933279999999999
    Current angles: [4.7   4.704 4.722 4.189 1.571 1.571 1.571 1.571]
    Current energy: 0.021374399999999995
    Current angles: [4.7   4.704 4.722 6.807 1.571 1.571 1.571 1.571]
    Current energy: 0.0869328
    Current angles: [4.7   4.704 4.722 4.189 1.571 1.571 1.571 1.571]
    Current energy: 0.0217008
    Current angles: [4.7   4.704 4.722 5.189 1.571 1.571 1.571 1.571]
    Current energy: 0.020087999999999988
    Current angles: [4.7   4.704 4.722 5.807 1.571 1.571 1.571 1.571]
    Current energy: 0.041102399999999976
    Current angles: [4.7   4.704 4.722 4.725 1.571 1.571 1.571 1.571]
    Current energy: 0.015000000000000027
    Current angles: [4.7   4.704 4.722 4.694 1.571 1.571 1.571 1.571]
    Current energy: 0.015009600000000026
    Current angles: [4.7   4.704 4.722 4.757 1.571 1.571 1.571 1.571]
    Current energy: 0.015057600000000025
    Current angles: [4.7   4.704 4.722 4.725 1.571 1.571 1.571 1.571]
    Current energy: 0.015000000000000027
    Current angles: [4.7   4.704 4.722 4.725 2.571 1.571 1.571 1.571]
    Current energy: 0.015009600000000038
    Current angles: [ 4.7    4.704  4.722  4.725 -0.047  1.571  1.571  1.571]
    Current energy: 0.015019200000000042
    Current angles: [4.7   4.704 4.722 4.725 1.571 1.571 1.571 1.571]
    Current energy: 0.015009600000000026
    Current angles: [4.7   4.704 4.722 4.725 0.953 1.571 1.571 1.571]
    Current energy: 0.015009599999999991
    Current angles: [4.7   4.704 4.722 4.725 0.571 1.571 1.571 1.571]
    Current energy: 0.015019200000000026
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.571 1.571 1.571]
    Current energy: 0.014999999999999996
    Current angles: [4.7   4.704 4.722 4.725 1.265 1.571 1.571 1.571]
    Current energy: 0.015009600000000019
    Current angles: [4.7   4.704 4.722 4.725 1.109 1.571 1.571 1.571]
    Current energy: 0.015019199999999979
    Current angles: [4.7   4.704 4.722 4.725 1.203 1.571 1.571 1.571]
    Current energy: 0.01500000000000003
    Current angles: [4.7   4.704 4.722 4.725 1.233 1.571 1.571 1.571]
    Current energy: 0.015009599999999988
    Current angles: [4.7   4.704 4.722 4.725 1.251 1.571 1.571 1.571]
    Current energy: 0.015019200000000038
    Current angles: [4.7   4.704 4.722 4.725 1.258 1.571 1.571 1.571]
    Current energy: 0.015000000000000027
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.571 1.571 1.571]
    Current energy: 0.01502880000000002
    Current angles: [4.7   4.704 4.722 4.725 1.262 2.571 1.571 1.571]
    Current energy: 0.015633600000000004
    Current angles: [ 4.7    4.704  4.722  4.725  1.262 -0.047  1.571  1.571]
    Current energy: 0.016190400000000004
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.571 1.571 1.571]
    Current energy: 0.015019200000000014
    Current angles: [4.7   4.704 4.722 4.725 1.262 0.953 1.571 1.571]
    Current energy: 0.015240000000000004
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.953 1.571 1.571]
    Current energy: 0.015086399999999996
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.597 1.571 1.571]
    Current energy: 0.01501919999999999
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.571 1.571]
    Current energy: 0.015000000000000027
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.571 1.571]
    Current energy: 0.015028800000000033
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.571 1.571]
    Current energy: 0.01500000000000003
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.571 1.571]
    Current energy: 0.015038400000000035
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 2.571 1.571]
    Current energy: 0.015518399999999996
    Current angles: [ 4.7    4.704  4.722  4.725  1.262  1.584 -0.047  1.571]
    Current energy: 0.016372800000000014
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.571 1.571]
    Current energy: 0.015028800000000026
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 0.953 1.571]
    Current energy: 0.01522080000000001
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.953 1.571]
    Current energy: 0.015067200000000027
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.64  1.571]
    Current energy: 0.015028800000000057
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.571]
    Current energy: 0.014999999999999986
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.606 1.571]
    Current energy: 0.015019200000000035
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.588 1.571]
    Current energy: 0.015009599999999984
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.599 1.571]
    Current energy: 0.015028800000000026
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.603 1.571]
    Current energy: 0.015028799999999998
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.604 1.571]
    Current energy: 0.015028800000000009
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.571]
    Current energy: 0.015028800000000023
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.571]
    Current energy: 0.015000000000000038
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 2.571]
    Current energy: 0.015460800000000031
    Current angles: [ 4.7    4.704  4.722  4.725  1.262  1.584  1.605 -0.047]
    Current energy: 0.016209599999999994
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.571]
    Current energy: 0.015028800000000023
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 0.953]
    Current energy: 0.015249600000000012
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.953]
    Current energy: 0.01511520000000002
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.568]
    Current energy: 0.015028800000000026
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.569]
    Current energy: 0.015009599999999991
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.569]
    Current energy: 0.015009599999999994
    Current angles: [4.7   4.704 4.722 4.725 1.262 1.584 1.605 1.569]
    Current energy: 0.01502880000000003
    Warning: Maximum number of function evaluations has been exceeded.
    Runtime: 5.0 minutes.

```python
print("Optimal angles:", res % (np.pi))
```

    Optimal angles: [1.559 1.562 1.58  1.584 1.262 1.584 1.605 1.569]

```python
"""Evaluate the cost using the complete (untruncated) Hamiltonian."""
copt = vqls.energy_sim(res, ham, circ, creg, lattice, shots=10_000)
print("Full optimal cost:", copt)
```

    Full optimal cost: 0.014419999999999954

```python
"""Get the quantum solution (x vector)."""
qxvec = vqls.qsolution(circ, res.x)
print(*np.real(qxvec), sep="\n")
```

```python
"""Compare to classical solution."""
# Get the classical solution
Amat = vqls.matrix(Acoeffs, Aterms)
bvec = vqls.vector(Bcoeffs, Bterms)
xvec = np.linalg.solve(Amat, bvec)
xvec /= np.linalg.norm(xvec)

# ===========================
# Compare classical x vectors
# ===========================

print("Comparing x vectors:")
print("====================")

# Compute the fidelity
fidelity = abs(np.dot(qxvec.conj(), xvec))**2
print(r"| < xquantum | xclassical > |^2 =", round(fidelity, 2))

# Compute the L2 distance
l2dist = np.linalg.norm(qxvec - xvec, ord=2)**2
print(r"|| xquantum - xclassical ||^2 =", round(l2dist, 2))

# ==========================
# Compare solution b vectors
# ==========================

print("\nComparing b vectors:")
print("====================")

# Compute |qbvec> = A |theta optimal>
qbvec = Amat @ qxvec
qbvec /= np.linalg.norm(qbvec)
bfidelity = abs(np.dot(bvec.conj(), qbvec))**2
print(r"| < bquantum | bclassical > |^2 =", round(bfidelity, 2))

bl2dist = np.linalg.norm(qbvec - bvec, ord=2)**2
print(r"|| bquantum - bclassical ||^2 =", round(bl2dist, 2))
```

## Running on a Quantum Computer

The experiments in this notebook are made into a script and run on Rigetti's Quantum Cloud Services. In particular, we used the Aspen-7 chip, the fidelity/noise characterization of which can be found online. In the following cells, we plot our results.

```python

```

## Error Mitigation

The QPU data above has a significant vertical shift from the simulator data, but generally the same "shape." This shift is present because of decoherence, gate application errors, measurement errors, and other noise in the system. It can be accounted for by running a set of "benchmark circuits" on the QPU prior to running the actual algorithm. These benchmark circuits are simple circuits, such as NOT and MEASURE, for which one knows the actual output. The vertical shift in this benchmark circuit can then be tested for and subtracted from the final computed energies to get more accurate results. Other methods are also possible, and this is an area of active research.

Here, we employ a similar idea, but instead of running a benchmark circuit, we just subtract off the difference of the initial energies on the QPU and on the simulator. The cell below implements this and plots the result again.

```python
"""Plot the error mitigated QPU energy vs iteration data obtained by running on Rigetti QCS."""
# Constant shift amount. In practice, this would be obtained by running a "benchmark circuit."
# Here, we just set the value based on the above curves
shift = 0.3

# Read in the files
qpu_energy1 = np.loadtxt("qpu-energy-iteration1.txt")
qpu_energy2 = np.loadtxt("qpu-energy-iteration2.txt")

# Subtract off the shift
qpu_energy1 -= shift
qpu_energy2 -= shift

# Do the plotting
plt.figure(figsize=(10, 5))
plt.plot(energies, "--o", linewidth=3, label="Simulator")
plt.xlabel("Iteration", fontsize=14, fontweight="bold")
plt.ylabel("Energy", fontsize=14, fontweight="bold")

plt.plot(qpu_energy1, "--o", linewidth=3, label="Error Mitigated QPU Run 1")
plt.plot(qpu_energy2, "--o", linewidth=3, label="Error Mitigated QPU Run 2")

# Put a line at the actual ground state energy (see below)
GSENERGY = 0.53232723
plt.plot(GSENERGY * np.ones_like(energies), linewidth=3, label="Analytic Energy")

plt.grid()
plt.legend();
```

## Conclusions

## Acknowledgements

Thank coauthors and Rigetti.

## References

[1] Carlos Bravo-Prieto, Ryan LaRose, M. Cerezo, Yigit Subasi, Lukasz Cincio, Patrick J. Coles, [Variational quantum linear solver: A hybrid algorithm for linear systems](https://arxiv.org/abs/1909.05820), 2019.

[2] Aram W. Harrow, Avinatan Hassidim, Seth Lloyd, [Quantum algorithm for solving linear systems of equations](https://arxiv.org/abs/0811.3171), 2008.

[3] Leonard Wossnig, Zhikuan Zhao, Anupam Prakash, [A quantum linear system algorithm for dense matrices](https://arxiv.org/abs/1704.06174), 2017.

[4] Yigit Subasi, Rolando Somma, David Orsucci, [Quantum algorithms for systems of linear equations inspired by adiabatic quantum computing](https://arxiv.org/abs/1805.10549), 2018.

[5] Alberto Peruzzo, Jarrod McClean, Peter Shadbolt, Man-Hong Yung, Xiao-Qi Zhou, Peter J. Love, Alán Aspuru-Guzik, Jeremy L. O'Brien, [A variational eigenvalue solver on a quantum processor](https://arxiv.org/abs/1304.3061), 2014.

[6] Jarrod R. McClean, Jonathan Romero, Ryan Babbush, Alán Aspuru-Guzik, [The theory of variational hybrid quantum-classical algorithms](https://arxiv.org/abs/1509.04279), 2015.

[7] Ryan LaRose et al., [Implementing variational quantum algorithms](https://github.com/rmlarose/qcbq/blob/master/tutorials/04qaoa/qcbq_tutorial4b_implement_qaoa-INSTRUCTOR.ipynb), MSU-IBM Quantum computing bootcamp with Qiskit, 2019.


# The Quantum Approximate Optimization Algorithm for MAX-CUT

The following is a step-by-step guide to running QAOA on the MaxCut problem.  In the debue paper on QAOA (arXiv: 1411.4028), Farhi, Goldstone, and Gutmann demonstrate that the lowest order approximation of the algorithm produced an approximation ratio of 0.6946 for the MaxCut problem on three-regular graphs.  You can use this notebook to set up an arbitary graph for MaxCut and solve it using the QAOA algorithm the Rigetti Forest service.

``pyQAOA`` is a python library that implements the QAOA.  It uses the `PauliTerm` and `PauliSum` objects from the pyQuil library for expressing the cost Hamiltonian and driver Hamiltonain. These operators are used to create a parametric pyQuil program and passed to the variational quantum eigensolver (VQE) solver in Grove.  VQE calls the Rigetti Forest QVM to exectue the Quil program that prepares the angle parameterized state.  There are multiple ways to construct the MAX-CUT problem for the QAOA library.  We include a method that accepts a graph and returns a QAOA instance where the costs and driver Hamiltonaians have been constructed.  The graph is either an undirected Networkx graph or a list of tuples where each tuple represents an edge between a pair of nodes.

We start by demonstrating the QAOA algorithm with the simplest instance of MAX-CUT--parititioning the nodes on a barbell graph.  The barbell graph corresponds to a single edge connecting two nodes.  The solution is the partitioning of the nodes into different sets $\{0, 1\}$.

```python
import pyquil.forest as qvm_module
import numpy as np
from grove.pyqaoa.maxcut_qaoa import maxcut_qaoa
barbell = [(0, 1)]  # graph is a defined by a list of edges.  Edge weights are assumed to be 1.0
steps = 1  # evolution path length between the ref hamiltonian and cost hamiltonian
inst = maxcut_qaoa(barbell, steps=steps)  # initializing problem instance
```

The cost Hamiltonian and driver Hamiltonian corresponding to the barbell graph are stored in `QAOA` object fields in the form of lists of PauliSums.

```python
cost_list, ref_list = inst.cost_ham, inst.ref_ham
cost_ham = reduce(lambda x,y: x + y, cost_list)
ref_ham = reduce(lambda x,y: x + y, ref_list)
print cost_ham
print ref_ham
```

The identity term above is not necessary to the computation since global phase rotations on the wavefunction do not change the expectation value.  We include it here purely as a demonstration.  The cost function printed above is the negative of the traditional maximum cut cost operator.  This is because QAOA is formulated as the maximization of the cost operator but the VQE algorithm in the pyQuil library performs a minimization.

QAOA requires the construction of a state parameterized by $\beta$ and $\gamma$ rotation angles
$$\begin{align}
\mid \beta, \gamma \rangle = \prod_{p=0}^{\mathrm{steps}}\left( U(\hat{H}_{\mathrm{drive}}, \beta_{p})U(\hat{H}_{\mathrm{MAXCUT}}, \gamma_{p}) \right)^{\mathrm{steps}} (\mid +\rangle_{N-1}\otimes\mid + \rangle_{N-2}...\otimes\mid + \rangle_{0}).
\end{align}$$
The unitaries $U(\hat{H}_{\mathrm{drive}}, \beta_{p})$ and $U(\hat{H}_{\mathrm{MAXCUT}}, \gamma_{p})$ are the exponentiation of the driver Hamiltonian and the cost Hamiltonian, respectively.
$$
\begin{align}
U(\hat{H}_{\mathrm{ref}}, \beta_{p}) = e^{-i \beta_{p} \hat{H}_{drive}} \\
U(\hat{H}_{\mathrm{MAXCUT}}, \gamma_{p}) = e^{-i \gamma_{p} \hat{H}_{\mathrm{MAXCUT}}}
\end{align}
$$

The QAOA algorithm relies on many constructions of a wavefunction via parameterized Quil and measurements on all qubits to evaluate an expectation value.  In order avoid needless classical computation, QAOA constructs this parametric program once at the beginning of the calculation and then uses this same program object throughout the computation.  This is accomplished using the `ParametricProgram` object from pyQuil that allows us to slot in a symbolic value for a parameterized gate.

The parameterized program object can be accessed through the `QAOA` method `get_parameterized_program()`.  Calling this method on an instantiated `QAOA` object returns a closure with a precomputed set of Quil programs.  Calling this closure with the parameters $\beta$ and $\gamma$ returns the circuit that has the rotations parameterized.

```python
param_prog = inst.get_parameterized_program()
prog = param_prog([1.2, 4.2])
print prog
```

The printed program above is a Quil program that can be executed on a QVM.  QAOA has two modes of operation: 1) pre-computing the angles of rotation classically and using the quantum computer to measure expectation values through repeated experiments and 2) installing the a classical optimization loop on top of step 1 to optimally determine the angles.  Operation mode 2 is known as the variational quantum eigensolver algorithm.  the `QAOA` object wraps the instantiation of the VQE algorithm with the `get_angles()` method.

```python
betas, gammas = inst.get_angles()
print betas, gammas
```

``get_angles()`` returns optimal the $\beta$ and $\gamma$ angles.  To view the probabilities of the state you can call ``QAOA.probabilities(t)`` were ``t`` is a concatentation of the $\beta$ and $\gamma$ angles, in that order.  The ``probabilities(t)`` routine takes the $\beta$ and $\gamma$ parameters, reconstructs the wave function and returns their coefficients.  A modified version can be used to print off the probabilities

```python
param_prog = inst.get_parameterized_program()
t = np.hstack((betas, gammas))
prog = param_prog(t)
wf, _ = inst.qvm.wavefunction(prog)
wf = wf.amplitudes
for ii in xrange(2**inst.n_qubits):
    print inst.states[ii], np.conj(wf[ii])*wf[ii]
```

As expected the bipartitioning of a graph with a single edge connecting two nodes corresponds to the state $\{|01\rangle, |10\rangle \}$.  In this trivial example the QAOA finds angles that construct a distribution peaked around the two degenerate solutions.

## MAXCUT on larger graphs and alternative optimizers.

Larger graph instances and different classical optimizers can be used with the QAOA.  Here we consider an 6-node ring of disagrees.  For an even number ring graph, the ring of disagrees corresponds to the antiferromagnet ground state--i.e. alternating spin-up spin-down.

```python
%matplotlib inline

from grove.pyqaoa.qaoa import QAOA
import networkx as nx
import matplotlib.pyplot as plt
from pyquil.paulis import PauliSum, PauliTerm
import pyquil.quil as pq
from pyquil.gates import H
import pyquil.forest as qvm_module
CXN = qvm_module.Connection()
```

```python
# define a 6-qubit ring
ring_size = 6
graph = nx.Graph()
for i in xrange(ring_size):
    graph.add_edge(i, (i + 1) % ring_size)
```

```python
nx.draw_circular(graph, node_color="#6CAFB7")
```

This graph could be passed to the `maxcut_qaoa` method and a `QAOA` instance with the correct driver and cost Hamiltonian could be generated as before.  In order to demonstrate the more general approach, along with some VQE options, we will construct the cost and driver Hamiltonians directly with `PauliSum` and `PauliTerm` objects.  To do this we parse the edges and nodes of the graph to construct the relevant operators.
$$
\begin{align}
\hat{H}_{\mathrm{cost}} = \sum_{\langle i, j\rangle \in E}\frac{\sigma_{i}^{z}\sigma_{j}^{z} - 1}{2} \\
\hat{H}_{\mathrm{drive}} = \sum_{i}^{n}-\sigma_{i}^{x}
\end{align}
$$
where $\langle i, j\rangle \in E$ refers to the pairs of nodes that form the edges of the graph.

```python
cost_operators = []
driver_operators = []
for i, j in graph.edges():
    cost_operators.append(PauliTerm("Z", i, 0.5)*PauliTerm("Z", j) + PauliTerm("I", 0, -0.5))
for i in graph.nodes():
    driver_operators.append(PauliSum([PauliTerm("X", i, 1.0)]))
```

We will also construct the initial state and pass this to the QAOA object.  By default `QAOA` uses the $|+\rangle$ tensor product state.  In other notebooks we will demonstrate that you can use the `driver_ref` optional argument to pass a different starting state for QAOA.

```python
prog = pq.Program()
for i in graph.nodes():
    prog.inst(H(i))
```

Now we are ready to instantiate the QAOA object!

```python
ring_cut_inst = QAOA(CXN, len(graph.nodes()), steps=1, ref_hamiltonian=driver_operators, cost_ham=cost_operators,
                     driver_ref=prog, store_basis=True, rand_seed=42)
```

```python
betas, gammas = ring_cut_inst.get_angles()
```

We are interested in the bit strings returned from the QAOA algorthm.  The `get_angles()` routine calls the VQE algorithm to find the best angles.  We can then manually query the bit strings by rerunning the program and sampling many outputs.

```python
from collections import Counter

# get the parameterized program
param_prog = ring_cut_inst.get_parameterized_program()
sampling_prog = param_prog(np.hstack((betas, gammas)))

# use the run_and_measure QVM API to prepare a circuit and then measure on the qubits
bitstring_samples = CXN.run_and_measure(sampling_prog, range(len(graph.nodes())), 1000)
bitstring_tuples = map(tuple, bitstring_samples)

# aggregate the statistics
freq = Counter(bitstring_tuples)
most_frequent_bit_string = max(freq, key=lambda x: freq[x])
print freq

print "The most frequently sampled string is ", most_frequent_bit_string
```

We can see that the first two most frequently sampled bit strings are the alternating solutions to the ring graph.  Since we have access to the wave function we can go one step farther and view the probability distrubtion over the bit strings produced by our $p = 1$ circuit.

```python
# plotting strings!
n_qubits = len(graph.nodes())
def plot(inst, probs):
    probs = probs.real
    states = inst.states
    fig = plt.figure()
    ax = fig.add_subplot(111)
    ax.set_xlabel("state",fontsize=20)
    ax.set_ylabel("Probability",fontsize=20)
    ax.set_xlim([0, 2**n_qubits ])
    rec = ax.bar(range(2**n_qubits), probs, )
    num_states = [0, int("".join(str(x) for x in [0,1] * (n_qubits/2)), 2),
              int("".join(str(x) for x in [1,0] * (n_qubits/2)), 2), 2**n_qubits - 1 ]
    ax.set_xticks(num_states)
    ax.set_xticklabels(map(lambda x: inst.states[x], num_states), rotation=90)
    plt.grid(True)
    plt.tight_layout()
    plt.show()
```

```python
t = np.hstack((betas, gammas))
probs = ring_cut_inst.probabilities(t)
plot(ring_cut_inst, probs)
```

For larger graphs the probability of sampling the correct string could be significantly smaller, though still peaked around the solution.  Therefore, we would want to increase the probability of sampling the solution relative to any other string.  To do this we simply increase the number of steps $p$ in the algorithm.  We might want to bootstrap the algorithm with angles from lower number of steps.  We can pass inital angles to the solver as optional arguments.

```python
# get the angles from the last run
beta = ring_cut_inst.betas
gamma = ring_cut_inst.gammas
# form new beta/gamma angles from the old angles
betas = np.hstack((beta[0]/3, beta[0]*2/3))
gammas = np.hstack((gamma[0]/3, gamma[0]*2/3))
# set up a new QAOA instance.
ring_cut_inst_2 = QAOA(CXN, len(graph.nodes()), steps=2, ref_hamiltonian=driver_operators, cost_ham=cost_operators,
                     driver_ref=prog, store_basis=True, init_betas=betas, init_gammas=gammas)
# run VQE to determine the optimal angles
betas, gammas = ring_cut_inst_2.get_angles()
t = np.hstack((betas, gammas))
probs = ring_cut_inst_2.probabilities(t)
plot(ring_cut_inst_2, probs)
```

We could also change the optimizer which is passed down to VQE through the QAOA interface.  Let's say I want to use BFGS or another optimizer that can be wrapped in python.  Simply pass it to `QAOA` through the `minimzer`, `minimizer_args`, and `minimizer_kwargs` keywords

```python
from scipy.optimize import fmin_bfgs

ring_cut_inst_3 = QAOA(CXN, len(graph.nodes()), steps=3, ref_hamiltonian=driver_operators, cost_ham=cost_operators,
                       driver_ref=prog, store_basis=True, minimizer=fmin_bfgs, minimizer_kwargs={'gtol':1.0e-3},
                       rand_seed=42)
betas, gammas = ring_cut_inst_3.get_angles()
t = np.hstack((betas, gammas))
probs = ring_cut_inst_3.probabilities(t)
plot(ring_cut_inst_3, probs)
```


# Hypersolvers vs purely data driven and analytical model

We want to compare the three approaches with dynamical systems whose vector field can be fully expressed by an analytical formulation here as following:

1. ODE integrators
2. Hypersolvers
3. Purely data-driven approach

Note:
These results are just to illustrate the differences and are not included in the final paper submission

```python
import sys; sys.path.append(2*'../') # go n dirs back
from src import *
```

```python
import time
import torch
import torch.nn as nn
import matplotlib.pyplot as plt

from torchdyn.core import NeuralODE
from torchdyn.datasets import *
from torchdyn.numerics import odeint, Euler, HyperEuler
```

## Model: damped controlled pendulum with torsional spring

```python
class ControlledPendulum(nn.Module):
    """
    Inverted pendulum with torsional spring
    """
    def __init__(self, u, m=1., k=.5, l=1., qr=0., β=1., g=9.81):
        super().__init__()
        self.u = u # controller (nn.Module)
        self.nfe = 0 # number of function evaluations
        self.cur_f = None # current function evaluation
        self.cur_u = None # current controller evaluation
        self.m, self.k, self.l, self.qr, self.β, self.g = m, k, l, qr, β, g # physics

    def forward(self, t, x):
        self.nfe += 1
        q, p = x[..., :1], x[..., 1:]
        self.cur_u = self.u(t, x)
        dq = p/self.m
        dp = -self.k*(q - self.qr) - self.m*self.g*self.l*torch.sin(q) \
            -self.β*p/self.m + self.cur_u
        self.cur_f = torch.cat([dq, dp], -1)
        return self.cur_f
```

```python
# Change device according to your configuration
# device = torch.device('cuda:1') if torch.cuda.is_available() else torch.device('cpu')
device = torch.device('cpu')
```

```python
from math import pi as π

# Time span
dt = 0.1  # this is the integration step. It is a bit large to more easily highlight the differences
t0, tf = 0, 2 # initial and final time for controlling the system
steps = int((tf - t0)/dt) + 1 # so we have a time step of 0.2s
t_span = torch.linspace(t0, tf, steps).to(device)
```

```python
# The controller is a simple MLP with one hidden layer with bounded output
class NeuralController(nn.Module):
    def __init__(self, model, u_min=-20, u_max=20):
        super().__init__()
        self.model = model
        self.u_min, self.u_max = u_min, u_max

    def forward(self, t, x):
        x = self.model(x)
        return torch.clamp(x, self.u_min, self.u_max)

model = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 1)).to(device)
u = NeuralController(model)
for p in u.model[-1].parameters(): torch.nn.init.zeros_(p)

# Controlled system
sys = ControlledPendulum(u).to(device)
```

## Hypersolvers for Controlled Systems [Semi data-driven]

By training the hypersolver on residuals between the ground-truth solution and the base solver (we will use Euler in this case) we can render the training more efficient while retaining solution accuracy.

The residuals can be defined by:
\begin{equation}
\begin{aligned}
    R \left(t_k,  x(t_k), x(t_{k+1}) \right) = \Phi(x(t_k), t_k, t_{k+1}) - x(t_k) - \epsilon f(x_k, u_k)
    \label{eq:residual_hypereuler}
\end{aligned}
\end{equation}

where $\Phi$ is an accurate solver and $\epsilon$ is the time stepping.

The hypernet $g_w$ should have as input data important features for predicting future trajectories: we can use the current state $x$, its derivative $f$ and the control input $u$. The training will be carried out by minimizing a loss function like:

\begin{equation}
    \ell = \frac{1}{K} \sum_{k=0}^{K-1} \left|\left|{ R \left( t_k,  x(t_k), x(t_{k+1}) \right) - g_\omega\left(x(t_k), f(t_k), u(t_{k}) \right) }\right|\right|_2
    \label{eq:loss_hypersolver}
\end{equation}

```python
# We consider the controller fixed during each solver step
class RandConstController(nn.Module):
    def __init__(self):
        super().__init__()
        self.u0 = torch.Tensor(1024, 1).uniform_(-10,10).to(device)

    def forward(self, t, x):
        return self.u0

# Save previously learned controller
u_no_hypersolver = sys.u
sys.u = RandConstController() # modify controller for training
```

```python
class VanillaHyperNet(nn.Module):
    """Simple hypernetwork for controlled systems
    Input: current x, f and u from the controlled system
    Output: p-th order residuals"""
    def __init__(self, net):
        super().__init__()
        self.net = net

    def forward(self, t, x):
        xfu = torch.cat([x, sys.cur_f, sys.cur_u], -1)
        return self.net(xfu)

net = nn.Sequential(nn.Linear(5, 32), nn.Softplus(), nn.Linear(32, 32), nn.Tanh(), nn.Linear(32, 2))
hypersolver = HyperEuler(VanillaHyperNet(net))
# model = nn.DataParallel(hypersolver, device_ids=[1]) # feel free to change here according to your setup and GPU available.
# model = model.to(device)
```

## Training the Hypernetwork

Training the hypernetwork $g_w$ requires us to know which set its input values are contained in.
If we want the pendulum with initial states in $\pm \pi$, we can sample the input states for the hypernetwork from a distribution which includes possible states which the system can visit (i.e., the hypernetwork should be able to predict outside of the initial boundaries in a certain range). For example, we can draw the states $x$ from a Uniform distribution:
$$x = \begin{bmatrix} q\\ p\\ \end{bmatrix} \sim Uniform(-2\pi, 2\pi) \times Uniform(-2\pi, 2\pi)$$
Similarly, the controller values can be sampled from a distribution including all the possible controller values, such as
$$u \sim Uniform(-20, 20)$$

```python
# Initial distribution
from math import pi as π
x0 = 2*π # limit of the state distribution (in rads and rads/second)
init_dist = torch.distributions.Uniform(torch.Tensor([-x0, -x0]), torch.Tensor([x0, x0]))

base_solver = Euler()
# Time span
t0, tf = 0, 2 # initial and final time for controlling the system
steps = int((tf - t0)/dt) + 1 # so we have a time step of 0.2s
t_span = torch.linspace(t0, tf, steps).to(device)
dt = (t_span[1] - t_span[0]).detach().cpu().item()
```

```python
opt = torch.optim.Adam(hypersolver.parameters(), lr=3e-4)
loss_func = nn.MSELoss()
epochs = 50000
bs = 2048
hypernet = hypersolver.hypernet
span = torch.linspace(0, dt, 2)
losses = []

for i in range(epochs):
    # Sample random intial states and controllers
    x0 = init_dist.sample((bs,)).to(device)
    sys.u.u0 = torch.Tensor(bs, 1).uniform_(-5, 5).to(device)

    # Compute residuals
    _, sol_gt = odeint(sys, x0, span, solver='tsit5', atol=1e-5, rtol=1e-5)[-1]
    sol = x0 + sys(0., x0)*dt
    residuals = (sol_gt - sol) / dt**2
    residuals_hypersolver = hypernet(0., x0)
    loss = loss_func(residuals, residuals_hypersolver)

    # Optimization step
    loss.backward(); opt.step(); opt.zero_grad()
    print(f'Step: {i}, Residual loss: {loss:.3f}', end='\r')
    losses.append(loss.detach().cpu().item())
```

    Step: 49999, Residual loss: 0.005

```python
fig, ax = plt.subplots(1, 1)
ax.plot(losses)
ax.set_yscale('log')
```

```python
torch.save(hypersolver, 'saved_models/hs_torchdyn.pt')
```

```python
# hypersolver = torch.load('saved_models/hs_torchdyn.pt').to(device)
```

## Purely data-driven approach

```python
class ControlledDataDrivenModel(nn.Module):
    """
    Data driven model of a controlled data-driven model
    """
    def __init__(self, net, u):
        super().__init__()
        self.net = net # data-driven model including the controller
        self.u = u # controller (nn.Module)
        self.nfe = 0 # number of function evaluations
        self.cur_u = None # current controller evaluation

    def forward(self, t, x):
        self.nfe += 1
        self.cur_u = self.u(t, x)
        dx = self.net(t, x, self.cur_u)
        return dx

class ModelNet(nn.Module):
    '''Simple MLP'''
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(3, 64),
                                 nn.Softplus(),
                                 nn.Linear(64, 64),
                                 nn.Softplus(),
                                 nn.Linear(64, 64),
                                 nn.Tanh(),
                                 nn.Linear(64, 2))

    def forward(self, t, x, u):
        return self.net(torch.cat([x, u], -1))
```

```python
model_net = ModelNet()
data_driven_sys = ControlledDataDrivenModel(model_net, RandConstController())
```

### Training the data-driven model

```python
opt = torch.optim.Adam(data_driven_sys.net.parameters(), lr=3e-4)
loss_func = nn.MSELoss()
epochs = 50000
bs = 2048
span = torch.linspace(0, dt, 2)
losses = []

for i in range(epochs):
    # Sample random intial states and controllers
    x0 = init_dist.sample((bs,)).to(device)
    control_input = torch.Tensor(bs, 1).uniform_(-5, 5).to(device)
    sys.u.u0 = control_input; data_driven_sys.u.u0 = control_input

    # Compute one step trajectory
    _, sol_gt = odeint(sys, x0, span, solver='tsit5', atol=1e-5, rtol=1e-5)
    _, sol_net = odeint(data_driven_sys, x0, span, solver='tsit5', atol=1e-5, rtol=1e-5)
    loss = loss_func(sol_gt, sol_net)

    # Optimization step
    loss.backward(); opt.step(); opt.zero_grad()
    print(f'Step: {i}, Loss: {loss:.6f}', end='\r')
    losses.append(loss.detach().cpu().item())
```

    Step: 49999, Loss: 0.000002

```python
fig, ax = plt.subplots(1, 1)
ax.plot(losses)
ax.set_yscale('log')
```

```python
torch.save(data_driven_sys.net, 'saved_models/pure_data_driven.pt')
```

### Uncontrolled trajectories

```python
# Sample and plot some trajectories
x0 = π # limit of the state distribution (in rads and rads/second)
init_dist = torch.distributions.Uniform(torch.Tensor([-x0, -x0]), torch.Tensor([x0, x0]))
x0 = init_dist.sample((10000,)).to(device)
sys = ControlledPendulum(RandConstController()).to(device)
control_input = torch.Tensor(10000, 1).uniform_(-3,3).to(device)

# Uncontrolled system
# control_input = torch.zeros(10000, 1)
sys.u.u0 = control_input; data_driven_sys.u.u0 = control_input

tf = 10
t_span = torch.linspace(0, tf, int(tf/dt)+1).to(device)

# Purely data-driven
_, traj_dd = odeint(data_driven_sys, x0, t_span, solver='euler') # we use rk4 to show that no matter how many times the VF is sampled, pure data-driven cannot compete with the hypersolver approach

# Hypersolver (analytical + data-driven)
_, traj_hyper = odeint(sys, x0, t_span, solver=hypersolver)

# Analytical methods
_, traj_euler = odeint(sys, x0, t_span, solver='euler')
_, traj_mp = odeint(sys, x0, t_span, solver='midpoint')
_, traj_rk4 = odeint(sys, x0, t_span, solver='rk4')
_, traj_gt = odeint(sys, x0, t_span, solver='tsit5', atol=1e-5, rtol=1e-5)
traj_euler = traj_euler.detach().cpu(); traj_hyper = traj_hyper.detach().cpu(); traj_gt = traj_gt.detach().cpu()
traj_mp = traj_mp.detach().cpu(); traj_rk4 = traj_rk4.detach().cpu()
traj_dd = traj_dd.detach().cpu()
t_span = t_span.cpu()

fig, axs = plt.subplots(2, 5, figsize=(20,6))

for i in range(5):
    for j in range(2):
        axs[j, i].plot(t_span, traj_euler[:,i,j], 'r')
        axs[j, i].plot(t_span, traj_hyper[:,i,j], 'orange')
        axs[j, i].plot(t_span, traj_mp[:,i,j], 'green')
        axs[j, i].plot(t_span, traj_rk4[:,i,j], 'purple')
        axs[j, i].plot(t_span, traj_dd[:,i,j], 'b-.')
        axs[j, i].plot(t_span, traj_gt[:,i,j], 'k:')
        axs[j, i].legend(['Euler', 'HyperEuler', 'Midpoint', 'RK4', 'Pure data-driven', 'Ground Truth'])
        axs[j, i].label_outer()
    axs[0, i].set_ylabel(r'Positions $q$'); axs[1, i].set_ylabel(r'Momenta $p$')

fig.suptitle('Uncontrolled Trajectories')
```

```python
# Error analysis
def smape(yhat, y):
    return torch.abs(yhat - y) / (torch.abs(yhat) + torch.abs(y)) / 2

err_dd = smape(traj_gt, traj_dd).detach().cpu()
err_euler = smape(traj_gt, traj_euler).detach().cpu();  err_hyper = smape(traj_gt, traj_hyper).detach().cpu()
err_mp, err_rk4 = smape(traj_gt, traj_mp).detach().cpu(), smape(traj_gt, traj_rk4).detach().cpu()

mean_dd, std_dd = err_dd.mean(1), err_dd.std(1)
mean_euler, std_euler = err_euler.mean(1), err_euler.std(1)
mean_hyper, std_hyper = err_hyper.mean(1), err_hyper.std(1)
mean_mp, std_mp = err_mp.mean(1), err_mp.std(1)
mean_rk4, std_rk4 = err_rk4.mean(1), err_rk4.std(1)

fig = plt.figure(figsize=(10,10))

alpha = .1

def plot_errors(ax, var=0, title='Positions', scale=None):
    ax.plot(t_span, mean_hyper[:,var], 'orange', label='HyperEuler (ours)')
    ax.plot(t_span, mean_dd[:,var], 'b-.', label='Purely Data-Driven')
    ax.plot(t_span, mean_euler[:,var], 'r', label='Euler')
    ax.plot(t_span, mean_mp[:,var], 'green', label='Midpoint')
    ax.plot(t_span, mean_rk4[:,var], 'purple', label='RK4')

    ax.fill_between(t_span, mean_euler[:,var]-std_euler[:,var], mean_euler[:,var]+std_euler[:,var], alpha=alpha, color='r')
    ax.fill_between(t_span, mean_hyper[:,var]-std_hyper[:,var], mean_hyper[:,var]+std_hyper[:,var], alpha=alpha, color='orange')
    ax.fill_between(t_span, mean_mp[:,var]-std_mp[:,var], mean_mp[:,var]+std_mp[:,var], alpha=alpha, color='green')
    ax.fill_between(t_span, mean_rk4[:,var]-std_rk4[:,var], mean_rk4[:,var]+std_rk4[:,var], alpha=alpha, color='purple')
    ax.fill_between(t_span, mean_dd[:,var]-std_dd[:,var], mean_dd[:,var]+std_dd[:,var], alpha=alpha, color='blue')
    ax.legend()
    if scale is not None:
        ax.set_yscale(scale)
    ax.set_title(title)
    ax.set_xlabel('Time [s]')
    ax.label_outer()

ax = fig.add_subplot(2,2,1)
plot_errors(ax, 0, 'Position error')

ax = fig.add_subplot(2,2,2)
plot_errors(ax, 1, 'Momenta error')

ax = fig.add_subplot(2,2,3)
plot_errors(ax, 0, 'Position error log', 'log')

ax = fig.add_subplot(2,2,4)
plot_errors(ax, 1, 'Momenta error log', 'log')

fig.suptitle('SMAPE Propagation')
```

## Comment
We can see that even if the data-driven vector field can learn the dynamics, even by integrating it for more times (in this case, with `RK4`), it still does not reach the `hypersolver` performance.\
Even if it did, we notice that it usually requires a larger network and more NFE. Most importantly, there is no error bound for this.
Here is a summary:

<center>

|                    | Classical | Hypersolvers             | Purely data-driven* |
|-------------------|----------------|--------------------------|--------------------|
| Vector field              | Analytical     | Analytical + data-driven | Data-driven        |
| ODE solver              | Classical     | Classical | Classical        |
| Network size      | n/a            | Smaller                  | Bigger             |
| Error bounds      | ☑              | ☑                        | ✘**                 |
| Pareto optimality | ✘              | ☑                        | ✘                  |


# Case 1: No Water Layer

* Author: **Team G15**
* Attempt: 3

## Analysis

### To find

1. Temperature of Roof Surface $(T_s)$
2. Total heat flux entering the house through the roof, $(q_t)$ when no water layer is present

### Nomenclature

* $T_s$ = roof surface temperature (outside)
* $T_a$ = ambient air temperature (outside)
* $T_r$ = room temperature (inside)
* $Nu_a$ = Nusselt number of air
* $Ra_a$ = Rayleigh number of air
* $Re_a$ = Reynolds number of air
* $Pr_a$ = Prandtl number of air
* $\alpha_a$ = thermal diffusivity of air
* $k_a$ = thermal conductivity of air
* $h_r$ = free convection coefficient of room air
* $\nu_a$ = dynamic Viscosity of air
* Roof layers:
  * 1: Concrete
  * 2: Brick
  * 3: Lime
* $k_i$ = thermal conductivity of $i^{th}$ roof layer
* $L_i$ = length of $i^{th}$ roof layer
* $q_{r}$ = radiative heat transfer (per unit area)
* $q_{c}$ = convective heat transfer (per unit area)
* $q_{t}$ = net heat transfer into the room (per unit area)
* $\beta$ = coefficient of thermal expansion
* $S$ = Intensity of Solar Radiation (i.e. solar constant)

### Assumptions

* Steady state with room maintained at fixed ambient temperature

### Equations

#### Energy balance,

$$ q_t = q_c + q_r $$

#### Radiation heat transfer,

\begin{align*}
  q_r &= \tau_s\cdot S - h_r \cdot (T_a - T_s) \\
\\
  h_r &= \epsilon_s\cdot \sigma\cdot \frac{(\overline T_s)^4 - (\overline T_a - 12)^4}{\overline T_a - \overline T_s}
\end{align*}

#### Convection heat transfer,

\begin{align*}
  q_c &= h_c\cdot (T_a - T_w) \\
\\
  h_c  &= \frac{k_a}{L_s}\cdot Nu_a \\
\\
  Nu_a &= 0.15\cdot Ra_a^{1/3} + 0.664\cdot Re_a^{1/2}\cdot Pr_a^{1/3} \\
\\
  Re_a &= \frac{v_a\cdot L_s}{\nu_a} \\
\\
  Ra_L &= \frac{g\cdot \beta\cdot (T_s - T_a)\cdot L_s^3}{\nu_a\cdot \alpha_a}
\end{align*}

#### Total heat transfer,

\begin{align*}
  q_t &= \frac{T_w - T_r}{R_{net}} \\
\\
  R_{net} &= \frac{1}{h_r} + \sum_{i=1}^{3} \frac{L_i}{k_i}
\end{align*}

### Properties

#### Outside Air

* Mild breeze $v_a = 2.78\ m/s$
* $T_a \in [305, 320] K$
* $T_f = 320K$
* $\beta = \frac{1}{T_f} = 0.0031\ K^{-1}$
* Table A.4, air ($T_f$):
  * $\nu = 18 \cdot 10^{-6}\ m^2/s$
  * $\alpha = 25 \cdot 10^{-6}\ m^2/s$
  * $Pr = 0.702$
  * $k = 27.7 \cdot 10^{-3}\ W/m\cdot K$
* $S = 1366\ W/m^2$

#### Roof

* $L_s = 5\ m$ (approx thickness of water layer)
* $\epsilon_s = 0.9$ (concrete surface)
* $\tau_s=0.9$
* $t = 0.2\ m$ thick with,
  * Cement = $5\ cm$
  * Brick = $10\ cm$
  * Lime = $5\ cm$
* $K_i$, Conductivity of each layer,
  * Cement = $0.72\ W/m\cdot K$
  * Brick = $0.71\ W/m\cdot K$
  * Lime = $0.73\ W/m\cdot K$

#### Inside air

* $T_r = 300K$ (Room Temperature)
* $h_r = 8.4\ W/m^2\cdot K$

### Tools used

* **Python**
* **SymPy** for creating symbolic equations and solving them
* **NumPy**
* **Matplotlib** for plotting results

## Solving (Python Code)

### Initialize Values

```python
%matplotlib inline
import sympy as sp
import numpy as np
import matplotlib.pyplot as plt

# Initialize matplotlib
plt.rc('text', usetex=True) # Unnecessary
plt.style.use('ggplot')
plt.rcParams['grid.color'] = '#C0C0C0'
```

#### Outside Air

* Table A.4 used (from reference #2)

```python
v_a = 2.78 # Velocity (m / s)

# Temperatures
T_f = 320.0 # (K)
beta = 1/T_f # (K)
T_a = np.array([305.0, 310.0, 315.0, 320.0]) # (K)
T_a_avg = 273 + 37 # (K)

# Universal Constants
sigma = 5.67e-8 # Stefan Boltzmann constant (W / m^2 * K^4)
g = 9.8 # (m / s^2)
S = 1366 # Solar constant

# Table A.6, air @ T = T_f
nu_a = 18e-6 # dynamic visosity (m^2 / s)
alpha_a = 25e-6 # (m^2 / s)
k_a = 27.7e-3 # thermal conductivity (W / m * K)
Pr_a = 0.702
```

#### Roof Layers

```python
# Temperatures
T_s = sp.symbols('T_s') # Roof surface temp (K)
T_s_avg = 273.0 + 35.0 # (K)

# Surface
L_s = 5 # Dimensions (m)
tau_s = 0.9 # Roof's solar absorbtivity
epsilon_s = 0.9 # Emissivity of roof surface (concrete)

# Layer 1: Concrete
k_1 = 0.72 # (W / m * K)
L_1 = 0.05 # (m)

# Layer 2: Brick
k_2 = 0.71 # (W / m * K)
L_2 = 0.10 # (m)

# Layer 3: Lime
k_3 = 0.73 # (W / m * K)
L_3 = 0.05 # (m)
```

#### Inside Air

```python
h_r = 8.4 # (W / m^2 * K)
T_r = 300 # (K)
```

### Equations

#### Radiation Heat

```python
h_r = epsilon_s * sigma * (T_s_avg**4 - (T_a_avg - 12)**4)/(T_a_avg - T_s_avg) # (W / m^2 * K)
q_r = tau_s * S - h_r * (T_a - T_s) # (W / m^2)

# Example at T_a = 310K and T_s = 314K
q_r_test = q_r[1].replace(T_s, 314)
print('Approximate value of q_r = %.2f W/m^2' % (q_r_test))
```

    Approximate value of q_r = 1343.00 W/m^2

#### Convection Heat

* From below analysis, we can neglect free convection in comparison to forced convection

##### Free Convection

```python
Ra_a = (g * beta * (T_s - T_a) * L_s**3) / (nu_a * alpha_a)
Nu_a_fr = 0.15 * Ra_a**(1/3)
h_c_fr = k_a / L_s * Nu_a_fr

# Example at T_a = 310K and T_s = 314K
h_c_fr_test = h_c_fr[1].replace(T_s, 314)
print('Approximate value of free convection coefficient = %.2f W/K*m^2' % (h_c_fr_test))
```

    Approximate value of free convection coefficient = 2.69 W/K*m^2

##### Forced Convection

```python
Re_a = v_a * L_s / nu_a
Nu_a_fo = 0.664 * Re_a**1/2 * Pr_a**1/3
h_c_fo = k_a / L_s * Nu_a_fo

# Example at T_a = 310K and T_s = 314K
print('Approximate value of forced convection coefficient = %.2f W/K*m^2' % (h_c_fo))
```

    Approximate value of forced convection coefficient = 332.36 W/K*m^2

##### Total Convection

```python
h_c = h_c_fo # Neglicting free convection
q_c = h_c * (T_a - T_s) # (W / m^2)

# Example at T_a = 310K and T_s = 314K
q_c_test = q_c[1].replace(T_s, 314)
print('Approximate value of q_c = %.2f W/m^2' % (q_c_test))
```

    Approximate value of q_c = -1329.43 W/m^2

#### Total Heat:

```python
R = 1/h_r + L_1/k_1 + L_2/k_2 + L_3/k_3 # (m^2 * K / W)

q_t = (T_s - T_r) / R # (W / m^2)

# Example at T_a = 310K and T_s = 314K
q_t_test = q_t.replace(T_s, 314)
print('Approximate value of q_t = %.2f W/m^2' % (q_t_test))
```

    Approximate value of q_t = 44.59 W/m^2

### Solving

\begin{align*}
  q_c + q_r &= q_t
\\
  \therefore\hspace{3pt} q_c + q_r - q_t &= 0
\end{align*}

#### Calculate $T_s$

```python
eq = q_c + q_r - q_t

n = len(eq)
T_s_calc = np.empty(n, dtype=object)

for i in range(n):
    T_s_calc[i] = round(sp.solve(eq[i], T_s)[0], 2)

for i in range(n):
    print('T_s = %.1f K for T_a = %.1f K' % (T_s_calc[i], T_a[i]))
```

    T_s = 309.0 K for T_a = 305.0 K
    T_s = 313.9 K for T_a = 310.0 K
    T_s = 318.9 K for T_a = 315.0 K
    T_s = 323.8 K for T_a = 320.0 K

#### Calculate $q_t$

```python
q_t_calc_1 = np.empty(n, dtype=object)

for i in range(n):
    q_t_calc_1[i] = q_t.replace(T_s, T_s_calc[i])

for i in range(n):
    print('Heat entering = %.1f W/m^2 for T_a = %.1f K' % (q_t_calc_1[i], T_a[i]))
```

    Heat entering = 28.5 W/m^2 for T_a = 305.0 K
    Heat entering = 44.3 W/m^2 for T_a = 310.0 K
    Heat entering = 60.0 W/m^2 for T_a = 315.0 K
    Heat entering = 75.8 W/m^2 for T_a = 320.0 K

### Plot

* Total Heat Flux Entering ($q_t$) vs Outside Air Temp ($T_a$)

```python
def make_plot(x, y, xlabel, ylabel, title):
    plt.plot(x, y, color='#1F77B4cc', marker='o')
    plt.xticks(fontsize=20)
    plt.yticks(fontsize=20)
    plt.xlabel(xlabel, fontsize=20)
    plt.ylabel(ylabel, fontsize=20)
    plt.title(title, fontsize=18, pad=15)
```

```python
fig = plt.figure(figsize=(10, 6))
make_plot(x=T_a, y=q_t_calc_1, xlabel='$T_a$', ylabel='$q_t$',
          title='Total Heat Flux Entering ($q_t$) vs Outside Air Temp ($T_a$)')
```

# Case 2: Water Layer

* Author: **Team G15**
* Attempt: 3

## Analysis

### To find

1. Temperature of Water Surface $(T_w)$
2. Total heat flux entering the house through the roof, $(q_t)$ when a water layer is present

### Nomenclature

* $S$ = Intensity of Solar Radiation (i.e. solar constant)
* $v_w$ = water velocity
* $v_a$ = wind velocity
* $\epsilon_w$ = emissivity of water surface
* $\sigma$ = Stefan-Boltzmann constant $(5.67*10^{-8}\ W/m^2K^4)$
* $T_r$ = room temperature (inside)
* $T_w$ = water surface temperature (outside)
* $T_a$ = ambient air temperature (outside)
* $\overline T_w$ = average water surface temperature (outside)
* $\overline T_a$ = average air temperature (outside)
* $\tau_w$ = fraction of solar radiation absorbed by water
* $k_w$ = thermal conductivity of water
* $L_w$ = length of water layer
* $h_w$ = convection coefficient of water layer
* $h_r$ = radiative heat transfer coefficient
* $h_c$ = convective heat transfer coefficient
* $h_e$ = evaporative heat transfer coefficient

### Assumptions

1. Steady state with room maintained at fixed ambient temperature
2. Water is still ($v_w = 0$) but gentle breeze is present ($v_a = 10\ km/h$)
3. Dry Surroundings

### Equations

#### Energy balance,

$$ q_t = q_c + q_r - q_e $$

#### Radiation heat transfer,

\begin{align*}
  q_r &= \tau_w\cdot S - h_r \cdot (T_a - T_w) \\
\\
  h_r &= \epsilon_w\cdot \sigma\cdot \frac{(\overline T_w)^4 - (\overline T_a - 12)^4}{\overline T_a - \overline T_w}
\end{align*}

#### Convection heat transfer,

\begin{align*}
  q_c &= h_c\cdot (T_a - T_w) \\
\\
  h_c &= 5.678 \cdot (1 + 0.85\cdot(v_a - v_w))
\end{align*}

#### Evaporative heat transfer,

\begin{align*}
  q_e &= 0.013\cdot h_c\cdot (p(\overline T_w) - \gamma\cdot p(\overline T_a)) \\
\\
  p(T) &= R_1\cdot T + R_2
\end{align*}

#### Total heat transfer,
\begin{align*}
  q_t &= \frac{T_w - T_r}{R_{net}} \\
\\
  R_{net} &= \frac{1}{h_r} + \sum_{i=1}^{3} \frac{L_i}{k_i} + \frac{1}{h_{w}} \\
\\
  h_w &= \frac{k_w}{L_w}\cdot (0.14\cdot(Gr\cdot Pr)^{1/3} + 0.644\cdot (Pr\cdot Re)^{1/3}) \\
\\
  Gr &= \frac{g\cdot\beta\cdot(T_w-T_a)\cdot(L_w)^{3}}{\nu^2}
\end{align*}

### Properties

#### Outside Air

* Mild breeze $v_a = 2.78\ m/s$
* $T_a \in [305, 320] K$
* $T_f = 320K$
* $\beta = \frac{1}{T_f} = 0.0031\ K^{-1}$
* Table A.4, air ($T_f$):
  * $\nu = 18 \cdot 10^{-6}\ m^2/s$
  * $\alpha = 25 \cdot 10^{-6}\ m^2/s$
  * $Pr = 0.702$
  * $k = 27.7 \cdot 10^{-3}\ W/m\cdot K$
* $S = 1366\ W/m^2$
* $R_1=325\ Pa/^\circ C$ and $R_2 = -5155\ Pa$ (*from reference* **#1**)
* $\gamma=0.27$ (approx average over a day)

#### Water layer

* $L_w = 0.1\ m$ (approx thickness of water layer)
* Table A.6, water ($T_w$):
  * $\nu = 18 \cdot 10^{-6}\ m^2/s$
* Still water $v_w = 0$
* $\epsilon_w = 0.95$
* $\tau_w=0.6$

#### Roof

* $t = 0.2\ m$ thick with,
  * Cement = $5\ cm$
  * Brick = $10\ cm$
  * Lime = $5\ cm$
* $K_i$, Conductivity of each layer,
  * Cement = $0.72\ W/m\cdot K$
  * Brick = $0.71\ W/m\cdot K$
  * Lime = $0.73\ W/m\cdot K$

#### Inside air

* $T_r = 300K$ (Room Temperature)
* $h_r = 8.4\ W/m^2\cdot K$

### Tools used

* **Python**
* **SymPy** for creating symbolic equations and solving them
* **NumPy**
* **Matplotlib** for plotting results

## Solving (Python Code)

### Initialize Values

#### Outside Air

* Saturation pressure of water p = R_1\*T + R_2

```python
v_a = 2.78 # Velocity (m / s)

# Temperatures
T_f = 320 # (K)
beta = 1/T_f # (K)
T_a = np.array([305.0, 310.0, 315.0, 320.0]) # (K)
T_a_avg = 273 + 37 # (K)

# Constants
sigma = 5.67e-8 # Stefan Boltzmann constant (W / m^2 * K^4)
g = 9.8 # (m / s^2)
R_1 = 325 # (N / m^2 °C)
R_2 = -5155 # (N / m^2)
gamma = 0.27
S = 1366 # Solar constant

def p(T): # Saturation pressure of water as a function of temperature (N / m^2)
    return R_1 * (T-273) + R_2
```

#### Water Layer

```python
v_w = 0 # Velocity (m / s)
L_w = 5 # Dimensions (m)

# Temperatures
T_w = sp.symbols('T_w') # (K)
T_w_avg = 273 + 32 # (K)

# Constants
epsilon_w = 0.95 # Emissivity of water surface
tau_w = 0.6 # Water's solar absorbtivity
```

* Table A.6 used (*from reference* **#2**)
* Upon analysing the below data, we can approximate $h_w$ to $950\ W/m^2$

```python
rho_w = 990 # density (kg / m^3)
k_w = 0.63 # thermal conductivity (W / m * K)
mu_w = 1e-6 * np.array([769, 695, 631, 577]) # viscosity (N * s / m^2)
nu_w = mu_w / rho_w # dynamic visosity (m^2 / s)

Pr_w = np.array([5.20, 4.62, 4.16, 3.77]) # Prandtl number
Re_w = 0 # Reynolds number, still water
Gr_w = g * beta * (T_a - T_w) * L_w**3 / nu_w**2 # Grashof number

# Water free convection coeffecient
h_w = (k_w/L_w) * (0.14 * (Gr_w*Pr_w)**(1/3) + 0.644 * (Pr_w*Re_w)**(1/3))

# Example at T_a = 310K and T_w = 306K
h_w_test = h_w[1].replace(T_w, 306)
print('Approximate min value of h_w = %.2f W/K*m^2' % (h_w_test))
```

    Approximate min value of h_w = 923.62 W/K*m^2

#### Roof Layers

```python
# Layer 1: Concrete
k_1 = 0.72 # (W / m * K)
L_1 = 0.05 # (m)

# Layer 2: Brick
k_2 = 0.71 # (W / m * K)
L_2 = 0.10 # (m)

# Layer 3: Lime
k_3 = 0.73 # (W / m * K)
L_3 = 0.05 # (m)
```

#### Inside Air

```python
h_r = 8.4 # (W / m^2 * K)
T_r = 300 # (K)
```

### Equations

#### Radiation Heat

```python
h_r = epsilon_w * sigma * (T_w_avg**4 - (T_a_avg - 12)**4)/(T_a_avg - T_w_avg) # (W / m^2 * K)
q_r = tau_w * S - h_r * (T_a - T_w) # (W / m^2)

# Example at T_a = 310K and T_w = 306K
q_r_test = q_r[1].replace(T_w, 306)
print('Approximate value of q_r = %.2f W/m^2' % (q_r_test))
```

    Approximate value of q_r = 786.53 W/m^2

#### Convection Heat

* Forced convection and free convection both have been used

```python
h_c = 5.678 * (1 + 0.85 * (v_a - v_w))
print('h_c = %.2f W/K*m^2' % (h_c))

q_c = h_c * (T_a - T_w) # (W / m^2)

# Example at T_a = 310K and T_w = 306K
q_c_test = q_c[1].replace(T_w, 306)
print('Approximate value of q_c = %.2f W/m^2' % (q_c_test))
```

    h_c = 19.10 W/K*m^2
    Approximate value of q_c = 76.38 W/m^2

#### Evaporation Heat:

```python
q_e = 0.013 * h_c * (p(T_w_avg) - gamma * p(T_a_avg)) # function p defined above, (W / m^2)

# Example at T_a = 310K and T_w = 306K
print('Approximate value of q_e = %.2f' % (q_e))
```

    Approximate value of q_e = 841.55

#### Total Heat:

```python
h_w = 1200 # from above approximation (W / m^2 * K)
R = 1/h_r + L_1/k_1 + L_2/k_2 + L_3/k_3 + 1/h_w # (m^2 * K / W)

q_t = (T_w - T_r) / R # (W / m^2)

# Example at T_a = 310K and T_w = 306K
q_t_test = q_t.replace(T_w, 306)
print('Approximate value of q_t = %.2f W/m^2' % (q_t_test))
```

    Approximate value of q_t = 14.98 W/m^2

### Solving

\begin{align*}
  q_c + q_r - q_e &= q_t
\\
  \therefore\hspace{3pt} q_c + q_r - q_e - q_t &= 0
\end{align*}

#### Calculate $T_w$

```python
eq = q_c + q_r - q_e - q_t

n = len(eq)
T_w_calc = np.empty(n, dtype=object)

for i in range(n):
    T_w_calc[i] = round(sp.solve(eq[i], T_w)[0], 2)

for i in range(n):
    print('T_w = %.1f K for T_a = %.1f K' % (T_w_calc[i], T_a[i]))
```

    T_w = 302.4 K for T_a = 305.0 K
    T_w = 306.5 K for T_a = 310.0 K
    T_w = 310.5 K for T_a = 315.0 K
    T_w = 314.6 K for T_a = 320.0 K

#### Calculate $q_t$

```python
q_t_calc_2 = np.empty(n, dtype=object)

for i in range(n):
    q_t_calc_2[i] = q_t.replace(T_w, T_w_calc[i])

for i in range(n):
    print('Heat entering = %.1f W/m^2 for T_a = %.1f K' % (q_t_calc_2[i], T_a[i]))
```

    Heat entering = 6.0 W/m^2 for T_a = 305.0 K
    Heat entering = 16.2 W/m^2 for T_a = 310.0 K
    Heat entering = 26.3 W/m^2 for T_a = 315.0 K
    Heat entering = 36.5 W/m^2 for T_a = 320.0 K

### Plot

* Temp Drop Due to Water ($T_a - T_w$) vs Outside Air Temp ($T_a$)
* Total Heat Flux Entering ($q_t$) vs Outside Air Temp ($T_a$)

```python
fig = plt.figure(figsize=(16, 6))

ax1 = fig.add_subplot(121)
make_plot(x=T_a, y=T_a-T_w_calc, xlabel='$T_a$', ylabel='$T_w$',
          title='Temp Drop Due to Water ($T_a - T_w$) vs Outside Air Temp ($T_a$)')

ax2 = fig.add_subplot(122)
make_plot(x=T_a, y=q_t_calc_2, xlabel='$T_a$', ylabel='$q_t$',
          title='Total Heat Flux Entering ($q_t$) vs Outside Air Temp ($T_a$)')

fig.tight_layout(w_pad=10)
```

## References

1. A. Shrivastava *et al*. ["Evaporative cooling model..."](https://github.com/relaxxpls/CL246-G15/blob/main/docs/papers/Experimental_validation_of_a_thermal_mod.pdf) (1984)
2. F. Incropera *et al*. ["Fundamentals of Heat and Mass Transfer"](https://books.google.co.in/books?id=5cgbAAAAQBAJ&newbks=0&hl=en&source=newbks_fb&redir_esc=y)


```python
import matplotlib.pyplot as plt
import pandas as pd
pd.set_option('display.latex.repr', True)
import sympy as sym
sym.init_printing(use_latex=True)
import numpy as np
from IPython.display import Image, Latex
```

# Markdown

## General

Some markdown text.

A list:

- something
- something else

A numbered list

1. something
2. something else

non-ascii characters TODO

This is a long section of text, which we only want in a document (not a presentation)
some text
some more text
some more text
some more text
some more text
some more text
some more text
some more text
some more text

This is an abbreviated section of the document text, which we only want in a presentation

- summary of document text

## References and Citations

References to \cref{fig:example}, \cref{tbl:example}, =@eqn:example_sympy and \cref{code:example_mpl}.

A latex citation.\cite{zelenyak_molecular_2016}

A html citation.<cite data-cite="kirkeminde_thermodynamic_2012">(Kirkeminde, 2012)</cite>

## Todo notes

\todo[inline]{an inline todo}

Some text.\todo{a todo in the margins}

# Text Output

```python
print("""
This is some printed text,
with a nicely formatted output.
""")
```

# Images and Figures

## Displaying a plot with its code

A matplotlib figure, with the caption set in the markdowncell above the figure.

The plotting code for a matplotlib figure (\cref{fig:example_mpl}).

# Tables (with pandas)

The plotting code for a pandas Dataframe table (\cref{tbl:example}).

```python
np.random.seed(0)
df = pd.DataFrame(np.random.rand(3,4),columns=['a','b','c','d'])
df.a = ['$\delta$','x','y']
df.b = ['l','m','n']
df.set_index(['a','b'])
df.round(3)
```

# Equations (with ipython or sympy)

```python
Latex('$$ a = b+c $$')
```

The plotting code for a sympy equation (=@eqn:example_sympy).

```python
y = sym.Function('y')
n = sym.symbols(r'\alpha')
f = y(n)-2*y(n-1/sym.pi)-5*y(n-2)
sym.rsolve(f,y(n),[1,4])
```


# Matrix Product State classifier for MNIST

## Introduction
Tensor networks are sparse data structures that have been originally developed in the area of
computational many-body quantum physics.

The aim of this notebook is to explain in a bit of detail how an MPS classifier works, and provide
some details of how to train it. As an example we will use the MNIST data set of hand written digits,
and the fashion-MNIST dataset of fashion products.

The content here follows closely the basic proposal by Miles Stoudenmire (arXiv: ...), but differs from
it in a few, but important aspect, and  I will explain these in more detail below. At this point, I'll just
summarize the advantages of my implementation as compared to Miles':
1. The network is inherently normalized, i.e. we don't need to regularize it
2. Overflow and underflow issues are eliminated. In particular, this includes:
    - normalization constants of projections of images into left and right environments
      can be rigorously ignored
    - simple gradient descent optimization is stable, i.e. it does not diverge if too large step sizes
      are taken

## Background
This notebook implements a linear classifier for classifying handwritten digits using a supervised learning
approach. The generic supervised learning setup consists of pairs $(x_n, y_n$) of data points.
$x_n$ here is a vector of features of the $n$-th input data (or sample), and $y_n$ is a
class label for this data point. In our example below, $x_n$ are the grey-scale values of a 2-d
array of pixels of an image of a handwritten digit (with digit-values between 0 andn 9),
and $y_n$ is the label indicating which digits is shown in the image. A supervised learning task
then consists of finding a mapping $\tilde y_n \equiv f(x_n)$ which assigns one of $L$ class labels to any
input data $x_n$. The assigned label $\tilde y_n$ should as often as possible coincide with the
correct label $y_n$.

Our implementation below consists of two steps. The first step is a so called feature embedding step.
Feature embedding here means that prior to feeding our data $x_n$ into our classification function $f$,
we use a non-linear embedding function $\tilde x_n \equiv \Phi(x_n)$ to map the datapoints $x_n$ into a high dimensional space. In general, there are many choices of functions $\phi(\cdot)$ one could use. We will below
use an embedding that has been proposed by Stoudenmire, but other choices are possible as well.
If our input data consists of values $x_n = (x_n^1, x_n^2, \cdots, x_n^P)$
of $P$ greyscale values, then the embedding function $\Phi$ is given by the outer product of $P$ local
embedding functions $\phi_k(\cdot)$. The local embedding function $\phi_k(\cdot)$ maps the value of pixel $k$
into a two-dimensional vector space:
\begin{align}
\phi_k^{i_k}:\mathbb{R}\rightarrow \mathbb{C}^2\\
x^k \rightarrow \big(\cos(\frac{\pi}{2}x^k), \sin(\frac{\pi}{2}x^k)\big) =(\phi^1_k(x^k), \phi^2_k(x^k))\equiv
\vec \phi_k(x^k)
\end{align}
(not that $k$ here is a superscript and not an exponent). The map $\Phi(\cdot)$ is then  given by
In the following we'll drop the subscript $k$ from $\phi$ to avoid clutter of notation.
\begin{align}
\Phi:\mathbb{R}^P\rightarrow \mathbb{C}^{2^P}\\
\tilde x_n \equiv \Phi(x_n) = \vec\phi(x^1_n)\otimes \cdots \otimes \vec\phi(x^P_n)=
\Phi^{i_1i_2\cdots i_P}(x_n) = \phi^{i_1}(x^1_n)\otimes \cdots \otimes \phi^{i_P}(x^P_n)
%(\cos(\frac{\pi}{2}x^1), sin(\frac{\pi}{2}x^1))\otimes
%(\cos(\frac{\pi}{2}x^2), sin(\frac{\pi}{2}x^2))\otimes \cdots \otimes(\cos(\frac{\pi}{2}x^P), sin(\frac{\pi}{2}x^P))
\end{align}
where we have introduced two different ways of denoting $\Phi$.

In the second step we will use a linear function $f(\cdot)$ to map the embedded feature vector $\tilde x_n$
back into an $L$ dimensional vector space.
In the implementation we're using below, we will then interpret a given orthogonal basis in this vector space
as corresponding to the class labels. We'll use below a so called one-hot encoding, which means that
a vector $(1,0,\cdots,0)$ corresponds to label $1$, $(0,1,0\cdots 0)$ to label 2,  $(0,0,1,0\cdots 0)$ to label 3,
and so on. Thus, the ideal mapping for any input data vector $x_n$ would be given by
\begin{align}
x_n \rightarrow \tilde y_n = f(\Phi(x_n)) = \delta_{y_n,l}
\end{align}
where $\delta_{y_n,l}$ is the Kronecker delta function ($l$ labels the index in the final $L$ dimensional
vector space). The goal is now to find a function $f$ which approximates the ideal mapping as good as possible.
In this notebook we will ilustrate how to use Matrix Product States (MPS) to implement this linear function $f$.

### Matrix Product States
Matrix product states (MPS) are efficient parametrizations of vectors in high-dimensional vector spaces.
Originally they have been developed to efficiently approximate ground-states of many-body quantum systems
in condensed matter physics, but they are now used well outside their original scope.
For the purpose of this notebook an MPS can be regarded as a high-dimensional parametrization of a tensor:
\begin{align}
T_{i_1 i_2 \cdots i_P} =  \sum_{\{\alpha_k\}} A^{i_1}_{\alpha_1} A^{i_2}_{\alpha_1\alpha_2}\dots A^{i_{P-1}}_{\alpha_{P-2}\alpha_{P-1}} A^{i_P}_{\alpha_{P-1}}
\end{align}
The $i_k$ are integers taking values in  $\{0,1\}$, and $A^{i_k}_{\alpha_{k-1}\alpha_k}$ are rank-3 tensors
with shapes $(D_{k-1},2,D_{k})$ for the index triple $(\alpha_{k-1}, i_k, \alpha_k)$
The tensors $A^{i_k}_{\alpha_{k-1}\alpha_k}$ will be optimized below.

### Classifier model
The clasifier in this notebook is given by an MPS of length $P+1$. The additional site
is the label tensor, which I'll denote with $B^{i_l}_{\alpha_{l-1}\alpha_l}$.
Here $i_l$ runs from 1 to $L$ ($L=10$ for MNIST and fashion MNIST).
The label tensor can be shifted around in the code.
The classifier is given by
\begin{align}
T_{i_1 i_2 \cdots i_P i_{P+1}} =  \sum_{\{\alpha_k\}} A^{i_1}_{\alpha_1} A^{i_2}_{\alpha_1\alpha_2}\dots A^{i_{P-1}}_{\alpha_{P-2}\alpha_{P-1}} A^{i_P}_{\alpha_{P-1}\alpha_P}B^{i_{P+1}}_{\alpha_P\alpha_{P+1}}
\end{align}
where we have arbitrarily put the label tensor to the right end of the MPS.
Classification is done by contracting the embedded image with the classifier and normalizing the result:

\begin{align}
f^{i_{P+1}}(\Phi(x_n)) = \sum_{i_k \neq i_{P+1}} T_{i_1 i_2 \cdots i_P i_{P+1}} \Phi^{i_1i_2\cdots i_P}(x_n)\\
\tilde y_n^{i_{P+1}} = \frac{f^{i_{P+1}}(\Phi(x_n)) }{\sqrt{\sum_{i_{P+1}}f^{i_{P+1}}(\Phi(x_n)) f^{i_{P+1}}(\Phi(x_n)) }}
\end{align}
As cost function we use the mean squared error:

\begin{align}
K = \frac{1}{2N}\sum_{n, i_{P+1}} (\tilde y_n^{i_{P+1}} - \delta_{y_ni_{P+1}})^2
\end{align}
The gradients can be worked out relatively easily (I'll add that later). One of the advantages of
the normalization is that 1) left and right embedded images (see the original publication) can be
normalized, and the normalizatio constant can be discarded (it will drop out of all equations)
and 2) we don't need to explicitly regularize the network, which has beneficial effects on the optimization.

dependencies:
you need `tensorflow 1.13` or higher, `numpy`, `TensorNetwork` and `matplotlib`.

```python
import tensorflow as tf
tf.enable_v2_behavior()
import tensornetwork as tn
import experiments.MPS_classifier.MPSMNIST as mm
import experiments.MPS.misc_mps as misc_mps
import experiments.MPS.matrixproductstates as MPS
import matplotlib.pyplot as plt
import numpy as np

misc_mps.compile_ncon(False)
%load_ext autoreload
%autoreload 2
```

```python
#mnist = tf.keras.datasets.mnist
mnist = tf.keras.datasets.fashion_mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()
#(x_train, y_train), (x_test, y_test) = mnist.load_data(path='mnist.npz')

Ntrain, Nx, Ny = x_train.shape

x_train = np.reshape(x_train,(Ntrain, Nx*Ny))
Ntest, Nx, Ny = x_test.shape
x_test = np.reshape(x_test,(Ntest, Nx*Ny))
print(x_train.shape, y_train.shape)
print(x_test.shape, y_test.shape)
```

```python
#optional shuffling of the data
x_train, new_order = mm.shuffle(x_train)
x_test, new_order = mm.shuffle(x_test, new_order)
```

```python
plt.imshow(np.reshape(x_train[np.random.randint(0,x_train.shape[0]),:],(28,28)))
```

```python
#optional downsampling by averaging over a square of 4 pixels
def downsample(X):
    """
    optional downsampling step for the data in `X`. downsamples by averaging over a square of 4 pixels
    """
    Nt = X.shape[0]
    bla = np.pad(np.reshape(X,(Nt,28,28)),pad_width=((0,0),(1,1),(1,1)),mode='constant')
    rolled1 = np.roll(bla,shift=(0,-1,-1),axis=(0,1,2))
    rolled2 = np.roll(bla,shift=(0,0,-1),axis=(0,1,2))
    rolled3 = np.roll(bla,shift=(0,-1,0),axis=(0,1,2))
    av = (bla + rolled1 + rolled2 + rolled3)/4
    downsampled = av[:,1:-1:2, 1:-1:2]
    dim = downsampled.shape[1]
    return np.reshape(downsampled,(Nt,dim**2))
x_train= downsample(x_train)
x_test= downsample(x_test)
print(x_train.shape, y_train.shape)
print(x_test.shape, y_test.shape)
```

prepare the samples into batches

```python
n_batches=50
train_data, train_labels = mm.generate_mapped_MNIST_batches(x_train,y_train,n_batches=n_batches,which='one_hot',scaling=1.0,
                                              shuffle_pixels=False)
test_data, test_labels = mm.generate_mapped_MNIST_batches(x_test, y_test,n_batches=1)
N = data[0].shape[2]
print(N)
```

```python
D=20
mps = mm.MPSClassifier.eye(ds = [2]*N, D=D, num_labels=10,label_position = 0,
                                 scaling=1.0, name='test',dtype=dtype)
mps.position(len(mps)) #the mps has an orthogonality center that can be shifted around
mps.position(0)
mps.normalize()#normalize the center matrix
```

```python
batch = 0 #use batch zero for fast training
samples = tf.convert_to_tensor(train_data[batch].astype(dtype.as_numpy_dtype))
labels = tf.convert_to_tensor(train_labels[batch].astype(dtype.as_numpy_dtype))
test_d = tf.convert_to_tensor(test_data[0].astype(dtype.as_numpy_dtype))
test_l = tf.convert_to_tensor(test_labels[0].astype(dtype.as_numpy_dtype))
#mps.compute_data_environments(samples)
```

```python
plt.imshow(np.reshape(samples[np.random.randint(0,samples.shape[0]),1,:],(28,28)))
```

```python
mps.label_position(0,D=D)
mps.position(0)
mps.compute_data_environments(samples)
```

```python
l, a = mps.left_right_sweep_label(samples,labels,learning_rate=1E-2,D=D) #start with label tensor at the left and;
                                                                         #shift it to the right end during first sweep
#l, a = mps.right_left_sweep_label(samples,labels,learning_rate=1E-8,D=D)
```

```python
mps.label_position(392,D=D) #for fashion MNIST, leave the label tensor at the right end
mps.position(0)
mps.compute_data_environments(samples)
```

```python
n0 = 250 #for fashion MNIST it turns out to give better results to stop a bit away from the left end
for _ in range(10):
    l, a = mps.left_right_sweep_simple(samples,labels,learning_rate=1E-4)
    l, a = mps.right_left_sweep_simple(samples,labels,learning_rate=1E-4, n0=n0)
```