# Exercise 2a: Modelling steady-state groundwater flow with python

Elco Luijendijk

November 2019

<elco.luijendijk@geo.uni-goettingen.de>

## Objectives

* Get familiar with the widely used scripting language Python
* Learn how to solve simple 1D steady-state groundwater flow in python

**Deadline**: **20 Dec 2019**. Hand in a version of your jupyter notebook and a short word document with answers to the assignments and the result figures of your numerical model.

**Grading**: Each assignment is 1 point, for a total of 4 points.

Do not hesitate to ask questions if you get stuck anywhere. You can reach me by email <eluijen@gwdg.de> or pass by at my office, room 122 in the Structural Geology dept.

*Good luck !*

## Introduction
In this exercise we will use a widely used and relatively simple programming or scripting language Python to solve the groundwater and heat flow equations in 1D. We will start with a tutorial of how to set up and run Python. Then we will use Python to solve the same groundwater flow equation as in exercise 1, make some figures of the model output and finally use automated calibration to explore the effect of measurement errors of hydraulic head on calibrated values of hydraulic conductivity.

## Starting python

We will use a Python distribution called Enthought Canopy. This is a software bundle that contains Python itself, a large numbers of additional modules and an editor. Canopy is free for academic use and is already installed on the computers in room MN13. If you want to install Canopy at home or on your laptop go to: <https://store.enthought.com/#canopy-academic> for the free academic version of Canopy or to: <https://store.enthought.com/downloads/#default> for Canopy express, a version that only contains the most essential modules.

You can find Canopy in Window's start menu on your computer. After you start Canopy you should get a window that looks like this:

Now start the editor. It should look something like this:

The editor contains three windows. On the bottom right there is the interpreter. Here you can directly type and execute python commands. For instance try typing:

After pressing enter you will see the computer trying to communicate with you.

On the upper right part of the window there is an editor. Here you can type a list of commands which can then be executed one by one. You can also load and save these lists of commands, which from now on we will call python scripts. It is custom to name python files with the extension .py like this: ``your_python_script.py``.

## Jupyter notebooks

There is however a much nicer way to work with Python and that is by working with Jupyter Notebooks. The document you are reading at the moment is a Jupyter notebooks. Notebooks contain a mix of text, Python or other code (R, Julia) and figures. The nice thing is that these notebooks are interactive: you can run the code in these notebooks and reproduce the figures in these notebooks. This makes for a nice and interactive way to analyze data or make simple numerical models as in this course.

For more information on Jupyter notebooks see here:
https://jupyter.org/

There are many examples of jupyter notebooks in the geosciences. A nice resource is this webpage that list several example notebooks:
http://geologyandpython.com/

## Python tutorial

To get a bit more familiar with Python we will work through part of an excellent online Python tutorial for scientists that you can find here: <http://nbviewer.ipython.org/gist/rpmuller/5920182>. We will restrict ourselves to part I and part II, up to the part on "One-Dimensional Harmonic Oscillator using Finite Difference". You can also skip the parts on tuples and dictionaries, since we will not use these. Take your time to go through this tutorial. Make sure you type all the commands correctly. While Python is probably the easiest programming language around, it is still very sensitive to typos. Programming languages and computers in general do exactly as they are told, but only exactly that.

## Set up a numerical model in Python

## Set up a numerical model in Python

As in exercise 1, we will again set up a numerical model that represents a simplified version of the typical global average watershed, using the simple conceptual model shown in the figure below. We will first solve the 1-dimensional form of the groundwater flow equation (see also your handout for exercise 1):

\begin{equation}
    \label{eq:explicit_steady_gwflow}
     h(x) = \frac{1}{2} \left( \frac{Wb \Delta x^2}{K b} + h(x+\Delta x) + h(x - \Delta x) \right)
\end{equation}

The parameters are given in the following table:

| L (m) |  b (m) |  h0 (m) |  R (m/yr) |  K (m/s)  | $\Delta x$ (m) |
| ----- | ------ | ------- | --------- | --------- | -------------- |
| 5000  |  250   |   250   | 0.25      | $10^{-5}$ | 100            |

And here is a figure of the simple groundwater flow system that we are trying to simulate:

## Setting up your model code

First the notebook contains some lines to import all the external modules that we need for this exercise. For this exercise we need numpy, for working with arrays, and matplotlib, to make some nice looking figures. FOr a nice overview of all the different graphics you cna make with python head over to the matplotlib website: https://matplotlib.org/

Note that any line starting with # is a comment and is ignored by Python. Try adding comments in your code to make reading the code and figuring out what it does easier later on. In the next lines we import the modules numpy and pyplot, which is part of matplotlib. We also specified ``as np``, which means that any numpy command we use must start with ``np.``, for example to take the square root of a number using numpy we should type ``np.sqrt(9)``. For pyplot we have to use the prefix ``pl.``. For example the following command: ``pl.plot([0, 2], [0, 2])`` will plot a straight line from (0, 0) to (2, 2).

```python
# importing external python modules
# numpy for working with arrays:
import numpy as np
# and matplotlib to make nice looking figures
import matplotlib.pyplot as pl
```

## Python functions

Next up there is a section that starts with ``def solve_steady_state_diffusion(...)``. This is a function. A function is an isolated part of the script that uses a set of input variables, does some operations with these variables (like solving the groundwater flow eq.) and returns one or more variables to the main script. You will need to adjust the function below later. For now lets skip the function and scroll down to the main part of the code.

Note that Python uses indents (empty spaces) to determine which part of the code belongs to which function or loop. This means that all indented code below the start of the function belongs to the function. You can add indents using the tab key.

~~~python
# an example of indentation:

# this line is not indented
    # this line is indented
~~~

```python
def solve_steady_state_diffusion_eq(dx, K, W, h0, n_iterations=1000):

    """
    this is a function where we will solve the steady-state diffusion equation
    for groundwater or heat flow

    this function receives 4 variables from the main code: dx, K, W, u0
    plus an optional variable n_iterations. The default value for this
    variable if not specified otherwise is 1000

    """

    # check the number of nodes in our numerical model:
    n_nodes = len(W)

    # set u an array to store the variable (ie, hydraulic head or temperature)
    h_new = np.zeros(n_nodes)

    # and set up a similar array to store the variable value of the previous
    # iteration step
    h_old = h_new.copy()

    # calculate the right hand term of the finite difference equation:
    C = W * dx**2 / K

    # set up a for loop to repeat the calculation n_iterations times
    for n_iter in range(n_iterations):

        # set up a new for loop to go through all the grid cells:
        for i in range(n_nodes):

            # check if we are at the left-hand boundary of the model domain
            if i == 0:
                # complete the next line and remove the comment sign (#)
                #h_new[0] = ....

            # check if we are at the right hand boundary instead
            elif i == n_nodes - 1:
                # complete the next line and remove the comment sign (#)
                #h_new[-1] = .... a function of h_old[-2]

            else:
                # add the equation for the middle nodes here:
                #h_new[i] = ..... a function of C[i], h_old[i-1] and h_old[i+1]

        # copy the new u into the u array for the previous timestep:
        h_old = h_new.copy()

    # done, you can now pass on the calculated value of u back to the main
    # part of the code:
    return h_new
```

Here is another function that does the same thing but faster. You will need to complete this function and run it later on in this exercise:

```python
def solve_steady_state_diffusion_eq_faster(dx, K, W, h0, n_iterations=1000):

    """
    this is a function where we will solve the steady-state diffusion equation
    for groundwater or heat flow

    this function receives 4 variables from the main code: dx, K, W, u0
    plus an optional variable n_iterations. The default value for this
    variable if not specified otherwise is 1000

    """

    # check the number of nodes in our numerical model:
    n_nodes = len(W)

    # set u an array to store the variable (ie, hydraulic head or temperature)
    h_new = np.zeros(n_nodes)

    # and set up a similar array to store the variable value of the previous
    # iteration step
    h_old = h_new.copy()

    # calculate the right hand term of the finite difference equation:
    C = W * dx**2 / K

    # set up a for loop to repeat the calculation n_iterations times
    for n_iter in range(n_iterations):

        # complete the next line and remove the comment sign (#)
        #h_new[0] = ....

        # complete the next line and remove the comment sign (#)
        #h_new[-1] = .... a function of h_old[-2]

        # add the equation for the middle nodes here:
        #h_new[1:-1] = ..... a function of C[1:-1], h_old[2:], h_old[:-2]

        # copy the new u into the u array for the previous timestep:
        h_old = h_new.copy()

    # done, you can now pass on the calculated value of u back to the main
    # part of the code:
    return h_new
```

## Add variables

The first thing to do is to define all parameters that we need. You can find the parameter values in table 1. Start adding all parameters that you need for the model in the cell below.

Note that it is a good habit to use decimal points to distinguish floating point numbers from integers, so ``L=5000.0`` instead of ``L=5000``. In the last case Python will assume L is an integer (ie, a whole number), which may result in counter-intuitive behaviour when using L in calculations later on.

Also note that all parameters should be given in SI units. To convert recharge from m/yr to m/s add something like this:

~~~~python
R = 0.25 / (365.25 * 24 * 60 * 60)
~~~~

go ahead and add all parameters that we need in the cell below

```python
#
year = 365.25 * 24 * 60 * 60.0

# uncomment (remove #) the following lines and add all the model parameters that you need:
#L = ...
#dx = ..
# add all other parameters in the following lines....

```

## Calculating the source term

After specifying all input parameters we need to calculate the fluid source term (*W*) using the equation given in exercise 1: $W = (R * \Delta x / (\Delta x * b))$:

```python
W = R # .... complete this equation ....
```

## Setting up arrays

For a number of variables like distance (x) and the source term (W) we need to set up arrays, ie. rows of numbers. This is similar to the columns of numbers that you used in your numerical model in excel. The following line will set up an array that represents distance (x):

~~~~python
x = np.arange(0, L + dx, dx)
~~~~

This generates an array that starts with 0, ends with the value ``L`` and has ``(L/dx + 1)`` nodes. For the source term W we can set up an array with the same length as array ``x`` in this way:

~~~~python
W_array = np.ones_like(x) * W
~~~~

This creates a new array with the same length as x. This new array is filled with the number one and then multiplied by the value of the source term (*W*) that we just calculated before.

Now go ahead and run the code below to set up the arrays that we need:

```python
# calculate the position of each node:
x = np.arange(0, L + dx, dx)

# set up an array with a source term for each node
W_array = np.ones_like(x) * W
```

Next we will check the content of the arrays that we have just set up:

```python
print('the array x contains the following numbers: ', x)
print('an W_array contains:', W_array)
```

Note that you can also select parts of arrays, as shown in the Python tutorial.
For instance to find the first value of x we can type the following:

```python
x[0]
```

Note that Python always starts counting at 0.
You can also start counting at the end by using negative numbers.
The last value of x can be found using:

```python
x[-1]
```

and finally you can alos select ranges of numbers. TO select all values of x except the first and the last you can type:

```python
x[1:-1]
```

## Using the steady-state diffusion eq. function:

Next we can already call our function (solve_steady_state_diffusion_eq) that solves the steady-state groundwater equation.

However, the function is not complete yet. If you go through the function you will notice a few lines that still have to be completed. The function starts with creating two new arrays, ``h_new`` and ``h_old``. These store the value of the variable you are trying to solve, which in this case is hydraulic head. The function solves the equation iteratively. After each iteration time step the newly calculated value ``h_new`` is copied to ``h_old`` and the iteration is repeated.

The iterations are executed using a so called for loop. The following line:

~~~~python
for n_iter in range(n_iterations):
~~~~

means that any code that is below this line and that is indented is repeated ``n_iterations`` times.

There is a second for loop that is inside the first for loop, which makes sure we go over each node in our model:

~~~~python
    # set up a for loop to repeat the calculation n_iterations times
    for n_iter in range(n_iterations):

        # set up a new for loop to go through all the grid cells:
        for i in range(n_nodes):
~~~~

The code in this second for loop does the actual calculation of the hydraulic head for each node and each iteration.

## Complete the equations

Next we have to make sure that the groundwater flow equation is solved correctly. There are three lines to complete in the function, one where the hydraulic head at the left hand side of the model domain is calculated, one that calculates value on the right hand side of the model domain, and one where you calculate hydraulic head in the remaining nodes in the middle.

Now go ahead and try to complete the line starting with ``#u_new[0] = ``:

~~~~python
# check if we are at the left-hand boundary of the model domain
if i == 0:
    # complete the next line and remove the comment sign (#)
    #h_new[0] = ....
~~~~

the variable ``h_new[0]`` means the value of ``h_new`` at the first node, which has node number 0. Note that python always starts to count at 0, and not 1 like for instance in Matlab. In exercise 1 we assigned a specified hydraulic head to the first node. The specified head is passed to the function as the variable ``h0``.

Next we can try to complete the line for the nodes in the middle:

~~~~python
else:
    # add the equation for the middle nodes here:
    #h_new[i] = ..... a function of C[i], h_old[i-1] and h_old[i+1]
~~~~

Look up the correct equation in your handout, modify the line and remove the # sign before the line to make it active. h_new[i] means the value of h_new at node number i. Note that i is part of the for loop: ``for i in range(n_nodes):``. THis means that everything below this loop is repeated and i is increased with one after completing each loop. ``h_old[i-1]`` means the value of h_old at node number i-1, which is the node before node i. And similarly ``h_old[i+1]`` means the value of h_old at node i+1, the next node.

Next we have to make sure that the right hand boundary acts as a no flow boundary. In excel we did this by making sure that the hydraulic head at a grid cell one row below the last grid cell always had the same value as the last grid cell. In python we can do the same by making sure that the value in the last node (``h_new[-1]``) is always the same as the second last node (``h_new[-2]``):

~~~~python
    # check if we are at the right hand boundary instead
    elif i == n_nodes - 1:
        # complete the next line and remove the comment sign (#)
        #h_new[-1] = .... a function of h_old[-2]
~~~~

The index ``[-1]`` is shorthand for the last item in an array. So ``h_new[-1]`` is the value of hydraulic head for the right most grid cell, and ``h_new[-2]`` is the second last node, etc...

Note that the term $\dfrac{Wb \Delta x^2}{K b}$ of the groundwater flow equation only needs to be calculated once and therefore can be kept outside the iteration loop. The term $\frac{Wb \Delta x^2}{K b}$ is stored in a new variable called ``C``.

## Running the model

Make sure you complete and run the diffusion function above and that the jupyter notebook does not generate an error if you do so. The reason is usually a typo or a wrong indentation. Try to fix this or call for help from your instructor if you cannot figure out the error.

Now try to run the model code by running the cell below. Watch the values of h increase towards a steady-state value (hopefully). Increase the number of iterations in the function if you need more steps to reach steady-state.

```python
# call the steady-state diffusion function to calculate h
h = solve_steady_state_diffusion_eq(dx, K, W_array, h0)
```

If everything works ok: congrats! You just wrote your very first numerical model code. You can inspect the modelled values of hydraulic head by making a new code cell by slecting the plus button above, and typing ``print(h)`` in this cell. If you want to only see part of the h array (which contains the modeled hydraulic head), you can for instance type ``print(h[10:20])`` to see the values of h for node 10 to 20.

If the code does not work: Do not panic. Go over your code to make sure there are no typos etc, you did not forget to define any variables, indentations are ok, etc... If the code is still not behaving: try to get the attention of your instructor or shout help.

## Adding the analytical solution

One good habit when running numerical models is to try and always find an analytical solution to test whether your numerical model is behaving well. Try to implement the analytical solution for the groundwater table that was shown in exercise 1. Complete the line that starts with ``#h_an = `` to calculate the analytical solution.

```python
# analytical solution for steady-state groundwater flow
# complete the line below:
#h_an = .....
```

## Graphical output

A computer model is really not complete without colourful pictures of the model result. Making nice-looking figures with Python is very easy thanks to the matplotlib module that we have imported already. For an overview of what you can do with matplotlib surf to the website and look at the gallery: <http://matplotlib.org/gallery.html>.

There are already a number of lines of code at the bottom of the script that will generate a figure of the model results.

The following line creates a new figure with one panel:

```python
# set up a figure with one panel
fig, panel = pl.subplots(1, 1)

# plot the analytical solution
panel.plot(x, h_an, color='green', label='h, analytical')

# and the numerical solution, add the right variables and uncomment (remove #) the next line:
#panel.plot(....)

# make the figure nicer:
panel.set_xlabel('Distance (m)')
panel.set_ylabel('Elevation (m)')
panel.legend(loc='upper left', fontsize='medium', frameon=False)

# save the figure:
fig.savefig('simulated_h.png')
```

note that you can rerun this block of code each time you change the numerical model to generate a new figure. Or you can copy paste the entire block and repeat it somewhere else as well.

# Assignments

be sure to answer assignments below marked in **bold**

**Assignment 1** Make a figure that contains the analytical and numerical values of h.

## Making the code faster
For loops in general make your code relatively slow. We can use numpy's functionality to avoid the inner for loop that cycles over the grid cells and try to calculate the new value of *h* for all nodes in one go at each timestep. We will implement the faster code in a new function called ``solve_steady_state_diffusion_eq_faster`` which you can find at the start of the notebook.

For this we need to remove the inner for loop (remove the line ``for i in range(n_nodes):``, and unindent the lines in this for loop) and replace the equations for *h*. The two lines for the boundary conditions can remain the same, since they do not depend on the value of ``i``, which tracks the node number. For all the nodes in between the boundary conditions, we can calculate *h* like this:

~~~~python
h_new[1:-1] = ... a function of C[1:-1], h_old[2:] and h_old[:-2]
~~~~

In this piece of code ``h_new[1:-1]`` means all grid cells except the first and last ones. ``h_old[2:]`` means all grid cells, except the first two, and ``h_old[:-2]`` means all grid cells except the last two. This statement does exactly the same as our for loop earlier, but many times faster.

After you are done with changing the code try to run the new faster model using the code below:

```python
h = solve_steady_state_diffusion_eq_faster(dx, K, W_array, h0)
```

**Assignment 2** Implement the new faster code below and run the numerical model again. Increase the amount of iterations (change the number in the first for loop) until the final solution does not change. How many timesteps do you need to have an error of *h* that is less than 1 cm?

## Automated model calibration

One of the advantages of Python is that over the last decade or so a lot of people have written a huge variety of Python modules that add all kinds of different useful functionality. One such module is called Scipy, <http://www.scipy.org>. This is a large collection of mathematical and scientific functions. Scipy contains a set of functions that deal with model calibration. We will use this to calibrate hydraulic conductivity in our numerical model. This is of course much faster and convenient than adjusting the values by hand as you've done in exercise 1.

The automated calibration module needs a second function that compares the modelled value of ``h`` to an observed value and then returns the model error. This function looks like this:

```python
def model_error(params, dx, W, h0, h_obs):

    K = params[0]

    # calculate the model predicted value of h
    h_pred = solve_steady_state_diffusion_eq(dx, K, W, h0)

    # calculate the absolute error between model and observed h
    h_error = np.abs(h_pred[-1] - h_obs)

    print('h error = ', h_error)

    return h_error
```

As you can see this function runs the model first and then calculates the absolute difference between *h* at the right hand node (``h_pred[-1]``) and some observed value ``h_obs``. The variable ``params`` is a list that contains all the parameters that we want to calibrate automatically. In this case we only calibrate ``K``, so ``params`` can be a list with only one value. We use the absolute model error, since the calibration function that we use is a functions that tries to minimise a value, ie.: it runs the function ``model_error`` again and again to find the lowest value of ``h_error``.

Now all we need to run automatic calibration is to call one of scipy's automatic calibration functions in the main code:

note that ``h_obs`` is the observed value of ``h`` at the right hand side of the model domain/the watershed boundary.

```python
import scipy.optimize as opt

h_obs = 350.0
params = [K]
params_calibrated = opt.fmin(model_error, params, args=(dx, W_array, h0, h_obs))
K = params_calibrated[0]

print('new calibrated value of K = ', K)
```

now we rerun the numerical model with the updated value of K:

```python
h = solve_steady_state_diffusion_eq_faster(dx, K, W_array, h0)
```

**Assignment 3:** Run the model with the new automatic calibration function. Check if the calibrated value of ``K`` is close to the value you calibrated manually in exercise 1. Use the new value of ``K`` to model hydraulic head and make a figure of this.

**Assignment 4:** Rerun the calibration with a new value of observed hydraulic head (``h_obs``) that reflects a typical measurement error for watertable measurements. The size of the error is up to you. You can think about direct measurement errors, but also errors that result from using a steady-state model for average conditions and ignoring seasonal changes. Report the error that you chose and the difference in ``K`` between the old calibration run and the new calibration run that includes the a new value of ``h_obs``. How big is the effect of measurement uncertainty on hydraulic conductivity, compared to the typical overall uncertainty of ``K`` that was discussed in the lecture? Does this mean that watertable data provide good or not so good constraints on hydraulic conductivity and permeability?

```python

```


```python
from sympy.matrices import Matrix, eye, zeros, ones, diag, GramSchmidt
import pandas as pd
# from IPython.display import display, Math, Latex, Markdown, HTML
# from sympy import *
from sympy.parsing.latex import parse_latex
import sympy as sp
import numpy as np
from string import Template
# from latex2sympy.process_latex import *
import matplotlib.pyplot as plt
plt.rcParams.update({'font.size': 20})
import json
import markdown
from markdown.extensions.toc import TocExtension
import uuid
import random
import math
import tqdm
# import copy
import seaborn as sns
# import requests
from Exercise import Exercise, Page, MarkdownBlock

from config import URL, TOKEN

Exercise.URL = "https://sympy-api.herokuapp.com/post"
Exercise.TOKEN = "TUDelftExerciseCreationToken"
```

```python
from sklearn.datasets import load_digits
digits = load_digits()

sorted_indices = np.argsort(digits.target)
nums = digits.images[sorted_indices]
targets = digits.target[sorted_indices]
```

```python
def to_binary(array):
    return np.where(array > 7, 1, 0)

def show(array, filename):
    data = np.where(array > 7, 1, 0)
    plt.matshow(data, cmap='gray_r')
    plt.axis('off')
    plt.savefig("{}.png".format(filename), dpi=500, bbox_inches='tight')
    plt.show()

f, axarr = plt.subplots(1,10)
unit = math.floor(len(nums)/10)
start_offset = math.floor((1/2)*unit)
for i in range(0, 10):
    axarr[i].axis('off')
    axarr[i].matshow(np.where(nums[start_offset + i*unit + 1] > 7, 1, 0), cmap="gray_r")

plt.savefig("all_digits.png", dpi=500, bbox_inches='tight')
show(nums[7], "zero")
show(nums[1500], "eight")
```

```python
fig, ax = plt.subplots()

ax.set_xlabel("column index")
ax.xaxis.set_label_position('top')
ax.set_xticklabels([i for i in range(0, 9)])

ax.set_ylabel("row index")
ax.yaxis.set_label_position('left')
ax.set_yticklabels([i for i in range(0, 9)])

data = np.where(nums[7] > 7, 1, 0)
# Minor ticks
ax.set_xticks(np.arange(-.5, 10, 1), minor=True)
ax.set_yticks(np.arange(-.5, 10, 1), minor=True)
ax.grid(which='minor', color='black', linestyle='-', linewidth=2)
ax.matshow(data, cmap='gray_r')

plt.savefig("zero_with_lables.png", dpi=500, bbox_inches='tight')
plt.show()
```

```python
p = Page()
p.reset()
```

```python
t1 = """
### Learning Goals
In this tutorial you will learn:

- Definitions of elementary data structures in linear algebra and operations on these;
- How the data structures can be used to model real-world artifacts (hand written digits);
- How operations on these data structures can be used to compare digital representations of hand written digits;
- The K-Nearest-Neighbours (KNN) algorithm to predict the digit in an unseen image.
"""
m = MarkdownBlock(t1)
m.write("Learning Goals")

t2 = """
### Digit Recognition in Nintendo DS Brain Training
You are hired by Nintendo to create some software to recognize user-written digits in the Brain Training game.
This is crucial element in the user interaction and therefore for the success of the game.
Answering by writing is way cooler then pressing the correct number on a virtual numpad.
You are given a digital representation of the input image and should provide a prediction of the corresponding digit.
This concept is formally known as [Optical Character Recognition](https://en.wikipedia.org/wiki/Optical_character_recognition).

<div class="d-flex flex-column flex-items-center">
    <div class="m-2">
    <a title="쿠도군, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons" href="https://commons.wikimedia.org/wiki/File:NDSL-Brain_Training_Korean_Version.png"></a>
    </div>
    <div class="m-2">

    </div>
</div>
"""
m = MarkdownBlock(t2)
m.write("Nintento DS Brain Training")

t3 = """
### The MNIST Dataset

To keep things simple throughout this tutorial, we will use a low resolution (8 by 8 pixels), binary (each pixel is either 0 (white) or 1 (black), representation of such digits.

<p align="center">

    <span class="img_caption" style="display: block; text-align: center;">Examples of hand-written digits</span>
</p>

A large dataset exists containing these images and corresponding labels (the digits being represented), called the [MNIST database](https://en.wikipedia.org/wiki/MNIST_database).
"""
m = MarkdownBlock(t3)
m.write("The MNIST Dataset")

```

```python
e = """
Classify the image below by typing the corresponding digit in the answer box.

<p align="center">

</p>
"""

feedback = """
Yes, that's right!
We humans generally do a good job classifying digits.
Our brains have seen many similar (hand-written) $0$'s before, stored in long term memory.
We compare the current $0$ to $0$'s seen earlier and classify it as similar.
Our final computer-based KNN-algorithm will do something similar.
"""

e1 = Exercise(e)
e1.add_answer(0, True, feedback)
e1.add_default_feedback("Hmm, that's not right. Both humans and computers make mistakes from time to time. Give it another try!")
e1.write("classify_0")
```

```python
e = """
Classify the image below by typing the corresponding digit in the answer box.

<p align="center">

</p>
"""

e2 = Exercise(MarkdownBlock(e))
e2.add_answer(8, True, "Yes, that's right! Again you did an excellent job. Next we will learn how this can be automated!")
e2.add_default_feedback("Hmm, that's not right. Both humans and computers make mistakes from time to time. Give it another try!")
e2.write("classify_8")
```

```python
p.append((MarkdownBlock(t1), [e1, e2]))
```

```python
md_2 = r"""
### Matrix
In mathematics we can represent a digit image in a data structure called a matrix.
You can think of a matrix as a grid with rows and columns forming cells, like in Excel or Google Sheets.
In our image, each cell is a pixel, and a pixel is either 0 (white) or 1 (black).

The digit

<p align="center">

</p>

corresponds to the matrix

<p align="center">
    $M = @m$
</p>

We humans likely find the image easier to recognize,
while computers prefer the concrete values in the matrix over analog colours shown on your display.

In mathematics, a matrix is referred to by a capital letter, like as $M$.
The values in the cells can be referenced by subscripting the corresponding non-capital letter $m$
with the row- and column index, $i$ and $j$ respectively, separated by a comma, like so: $m_{i, j}$.
For example, in the matrix $M$ above, $m_{2, 3} = 1$ and $m_{3, 2} = 0$.
"""

params = {}
params["m"] = sp.Matrix(to_binary(nums[7]))
m = MarkdownBlock(md_2, params)
m.write("matrix")
```

```python
e = """
Consider the matrix $A$ below, what is the value at $a_{3, 5}$?
<p align="center">
$A = @a$
</p>
"""

params = {}
arr = np.arange(25).reshape((5,5))
params["a"] = sp.Matrix(arr)

e3 = Exercise(MarkdownBlock(e, params))
e3.add_answer(sp.simplify(arr[2,4]), True, "Correct!")
e3.add_answer(sp.simplify(arr[4,2]), False, "🤔 You seem to have swapped the row and column index.")
e3.write("matrix")
```

```python
p.append((m, [e3]))
```

```python
md_3 = """
### Difference Matrix
Given two matrices, how can we determine their similarity?
For this, we use the notion of distance.
A simple distance measure is to count the number of overlapping places, "the pixels on which they agree".
Given two matrices $A$ and $B$, their similarity can be represented by the distance matrix $S$:

$s_{i,j} = \\begin{cases}
    0 &\\text{if } a_{i,j} = b_{i,j} \\text{ "the pixels agree"} \\\\
    1 &\\text{if } a_{i,j} \\neq b_{i,j} \\text{ "the pixels disagree"}
    \\end{cases}$

Example:

In maths:

<p align="center">
    $A = @a$, $B = @b$, $S = @s$
</p>

and corresponding visuals:

<p align="center">

</p>

For digits, this can be visualized as follows:

<div class="d-flex flex-justify-center">

</div>
"""

i0 = np.array([1,0,1,
               1,0,1,
               1,1,1]).reshape((3,3))
i1 = np.array([0,0,0,
               1,0,1,
               1,1,1]).reshape((3,3))

params_text = {}
params_text["a"] = sp.Matrix(i0)
params_text["b"] = sp.Matrix(i1)
params_text["s"] = sp.Matrix(np.abs(i0 - i1))
m = MarkdownBlock(md_3, params_text)
m.write("difference matrix")
```

```python
e_4 = """
Compute the similarity matrix between $A$ and $B$, given the similarity metric defined above.

$A = @a, B = @b$
"""

params = {}

a = np.array([1,0,1,1])
b = np.array([0,1,1,0])

params["a"] = sp.Matrix(2, 2, a)
params["b"] = sp.Matrix(2, 2, b)

e4 = Exercise(MarkdownBlock(e_4, params))
e4.add_answer(sp.Matrix(2,2, np.abs(a - b)), True, "Yes!")
e4.write("difference_matrix")
```

```python
p.append((m, [e4]))
```

```python
md4 = """
### Distance Score
We can reducre the distance matrix $S$ to a distance score by taking it's sum, that is: add together all values.

The maximum distance score is 64, since there are 64 pixels in each image.
The lower the distance score, the more similar two images are.
We can see that a $0$ is more similar to another $0$ than to a $9$, because the distance score is lower.

<p align="center">

</p>
"""

md4 = MarkdownBlock(md4)
md4.write("Distance Score")
```

```python
e5 = """
Given distance matrix $S$, what is the distance score corresponding to $S$?

$S = @s$
"""

params = {}
data = [np.random.randint(0,2) for _ in range(9)]
s = sp.Matrix(3, 3, data)
params["s"] = s

e5 = Exercise(MarkdownBlock(e5, params))
e5.add_answer(sp.simplify(np.array(data).sum()), True, MarkdownBlock("Correct!"))
e5.write("distance_score")
```

```python
e6 = """
Given matrices $A = @a$ and $B = @b$, what is their distance score?
"""

a = [np.random.randint(0,2) for _ in range(9)]
b = [np.random.randint(0,2) for _ in range(9)]

params = {}
params["a"] = sp.Matrix(3,3, a)
params["b"] = sp.Matrix(3,3, b)

e6 = Exercise(MarkdownBlock(e6, params))
ans = np.abs(np.array(a) - np.array(b)).sum()
e6.add_answer(sp.simplify(ans), True, "Yes!")
e6.write("distance_score_from_scratch")
```

```python
p.append((md4, [e5, e6]))
```

```python
md5 = """
### From distance score to 'neighbour'
The distance score can be used do define the notion of neighbour proximity.
The lower the distance score, the closer the neighbour.

In the image below, the digit on the left is an unseen digit, we want to determine whether it is a $0$ or a $9$.
We compute the distance score between this unknown digit and a dataset with the same amount of $0$'s and $9$'s for which we know the digit number.

<p align="center">

</p>

We sort the computed distance scores and only consider the $K$ lowest distances.
The majority of the known digit numbers belonging to those distances determines whether we classify this unseen digit as a $0$ or a $9$.
In the image above, if we take $K=5$, the digits are $0,0,0,9,0$, of which the majority is $0$, so we classify this digit as a $0$.

In the image below, one can see that the differences between digits of the same class are generally smaller than differences between classes.
In other words, an arbitrary four is likely more similar to another four then to another digit ($K=1$).
With a higher value for $K$, accuracy is expected to go up.
What the ideal value is for $K$ and how to determine this is yet to be seen.

<p align="center">

</p>
"""
md5 = MarkdownBlock(md5)
md5.write("knn")
```

```python
e = """
Consider the dataset below:

<p align="center">

</p>

How is the digit on the left classified when $K=3$?
"""

e = Exercise(e)
e.add_answer(sp.simplify(0), True, "Indeed! The labels are: $0,0,9$, therefore the majority vote is $0$!")
e.write("knn_1")
```

```python
e2 = """
Consider the dataset below again:

<p align="center">

</p>

How is the digit on the left classified when $K=4$?
"""

fb = "It could be either a $0$ or a $9$, we didn't specify the rules for a tie in similarity-scores, nor for a tie between classes"
e2 = Exercise(e2)
e2.add_answer(sp.simplify(0), True, fb)
e2.add_answer(sp.simplify(9), True, fb)
e2.write("knn_2")
```

```python
p.append((md5, [e, e2]))
```

```python
md = """
### To be continued...

- performance evaluation: confusion matrix for simplest complex case -> binary case (0 or 9)
- extending to multi-class classification
- extending to non-binary images
- higher resolution images (i.e. more dimensions)
- other distance functions
- preprocessing the data (e.g. first derivative)
- other classifyers (gradient boosting, decision trees, neural networks, PCA etc.) should be good to demonstrate, understand and execute by hand on the MNIST problem
- extending to EMNIST dataset

"""
md = MarkdownBlock(md)
md.write("tbc")
```

```python
p.append((md, []))
p.write()
```

    Data written succesfully.

```python
fig, axes = plt.subplots(nrows=1, ncols=1, figsize=(10, 10))
axes.axis("off")
axes.matshow(nums[1103], cmap="Greys")

# plt.savefig("six_new.png", dpi=500, bbox_inches='tight')

import matplotlib.patheffects as pe
for (i, j), z in np.ndenumerate(nums[1103]):
    axes.text(j, i, '{}'.format(int(z)), ha='center', va='center', path_effects=[pe.withStroke(linewidth=4, foreground="white")])

plt.savefig("six_new.png", dpi=500, bbox_inches='tight')

plt.show()

```

```python
e = """
$@m$
"""

params = {}
ans = sp.Matrix([1,2,3,4])
params["m"] = ans

e = Exercise(MarkdownBlock(e, params))
e.add_answer(ans, True, MarkdownBlock("YESS!!"))
e.write()

t.append((MarkdownBlock(""), [e]))
t.write()
```

```python
def show_grid(ax):
    ax.set_xticklabels([])
    ax.set_yticklabels([])
    ax.tick_params(axis=u'both', which=u'both',length=0)
    ax.set_xticks(np.arange(-.5, 10, 1), minor=True)
    ax.set_yticks(np.arange(-.5, 10, 1), minor=True)
    ax.grid(which='minor', color='black', linestyle='-', linewidth=1)

fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(9, 3))
print(axes)
axes[0].set_title("A")
axes[1].set_title("B")
axes[2].set_title("S")

i0 = np.array([1,0,1,
               1,0,1,
               1,1,1]).reshape((3,3))
i1 = np.array([0,0,0,
               1,0,1,
               1,1,1]).reshape((3,3))

show_grid(axes[0])
axes[0].matshow(i0, cmap="gray_r")

show_grid(axes[1])
axes[1].matshow(i1, cmap="gray_r")

show_grid(axes[2])
similarity_matrix = sp.Matrix((i0 - i1)**2)
numpy_sim_matrix = np.array(similarity_matrix).astype(np.int32)
axes[2].matshow(numpy_sim_matrix, cmap="gray_r", vmin=0, vmax=1)

plt.savefig("similarity_single.png", dpi=500, bbox_inches='tight')
```

```python
tuples = [(nums[7], nums[7]),
          (nums[7], nums[1]),
          (nums[7], nums[1700])]

fig, axes = plt.subplots(nrows=len(tuples), ncols=5, figsize=(14, 8))

def show_grid(ax):
    ax.set_xticklabels([])
    ax.set_yticklabels([])
    ax.tick_params(axis=u'both', which=u'both',length=0)
    ax.set_xticks(np.arange(-.5, 10, 1), minor=True)
    ax.set_yticks(np.arange(-.5, 10, 1), minor=True)
    ax.grid(which='minor', color='black', linestyle='-', linewidth=1)

axes[0][0].set_title("A")
axes[0][1].set_title("B")
axes[0][2].set_title("O = A+B")
axes[0][3].set_title("D = |A-B|")
axes[0][4].set_title("distance = $\sum D$")

for i, t in enumerate(tuples):
    cmap = 'gray_r'
    i0 = to_binary(t[0])
    i1 = to_binary(t[1])

    show_grid(axes[i][0])
    axes[i][0].matshow(i0, cmap=cmap)

    show_grid(axes[i][1])
    axes[i][1].matshow(i1, cmap=cmap)

    show_grid(axes[i][2])
    o = i0 + i1
    axes[i][2].matshow(o, cmap=cmap)

    show_grid(axes[i][3])
    similarity_matrix = sp.Matrix((i0 - i1)**2)
    numpy_sim_matrix = np.array(similarity_matrix).astype(np.int32)
    axes[i][3].matshow(numpy_sim_matrix, cmap=cmap, vmin=0, vmax=1)

    import matplotlib.patheffects as pe
    for a_index, a_mtx in enumerate([i0, i1, o, numpy_sim_matrix]):
        for (i_loop, j_loop), z in np.ndenumerate(a_mtx):
            value = ""
            try:
                value = int(z)
            except:
                value = ""
            axes[i][a_index].text(j_loop, i_loop, '{}'.format(value), ha='center', va='center', path_effects=[pe.withStroke(linewidth=4, foreground="white")], fontsize="xx-small")

    draw_numberline(numpy_sim_matrix.sum(), axes[i][4])

plt.savefig("similarity.png", dpi=500, bbox_inches='tight')
```

```python
n = lambda: nums[np.random.randint(low=0, high=len(nums))]
tuples = [(n(), n()),
          (n(), n()),
          (n(), n())]

def show_grid(ax):
    ax.set_xticklabels([])
    ax.set_yticklabels([])
    ax.tick_params(axis=u'both', which=u'both',length=0)
    ax.set_xticks(np.arange(-.5, 10, 1), minor=True)
    ax.set_yticks(np.arange(-.5, 10, 1), minor=True)
    ax.grid(which='minor', color='black', linestyle='-', linewidth=1)

# for i, t in enumerate(tuples):
def two_nums_overlay_to_img(filename, a, b):
    cmap = 'gray_r'

    fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(14, 8))
    i0 = a
    i1 = b

    show_grid(axes[0])
    axes[0].matshow(i0, cmap=cmap)

    show_grid(axes[1])
    axes[1].matshow(i1, cmap=cmap)

    show_grid(axes[2])
    o = i0 + i1
    axes[2].matshow(o, cmap=cmap)

    import matplotlib.patheffects as pe
    for a_index, a_mtx in enumerate([i0, i1, o]):
        for (i_loop, j_loop), z in np.ndenumerate(a_mtx):
            axes[a_index].axis("off")

    plt.savefig("{}.png".format(filename), dpi=200, bbox_inches='tight')
```

```python
from matrix_tools import *

m = """
A possible way to compare digits is by counting the amount of non-overlapping pixels when putting two digits on top of each other.
Consider the two leftmost digits and placed on top of each other on the right.
Determine the amount of non-overlapping pixels (displayed in grey) between the two digits.

<div class="d-flex flex-1 flex-items-center">

</div>
"""

feedback_correct = """
Indeed, these digits have $@count$ non-overlapping pixels.
"""

feedback_incorrect = """
Does this grid outline help in counting?

<div class="d-flex flex-1 flex-items-center flex-justify-center">

</div>
"""

def generator():
    a = to_binary(nums[np.random.randint(350)])
    b = to_binary(nums[np.random.randint(350)])
    two_nums_overlay_to_img("o", a, b)

    e = Exercise(m)
    count = sp.simplify(np.sum(np.abs(a - b), dtype=int))

    params = {}
    params["count"] = count
    e.add_answer(count, True, MarkdownBlock(feedback_correct, params))

    s = a + b
    matrix_to_image(s, "o_grid")
    e.add_default_feedback(MarkdownBlock(feedback_incorrect))
    return e

Exercise.write_multiple(generator, 10, "overlapping_pixels")
```

```python
from matrix_tools import *
m = """
Try to manually classify the digit below
<div class="d-flex flex-1 flex-justify-center">

</div>
"""

feedback_correct = """
Yes, that's right!
We humans generally do a good job classifying digits.
"""

feedback_incorrect = """
Hmm, that's not right.
Properly classifying this digit can be difficult due to the low resolution of the image or imperfect writing.
The final algorithm won't perform perfect either.
Give it another thy!
"""

def generator():
    index = np.random.randint(len(nums))
    a = nums[index]
    matrix_to_image(to_binary(a), "d", False, False, False, False)
    e = Exercise(m)

    label = targets[index]
    e.add_answer(sp.simplify(label), True, feedback_correct)
    e.add_default_feedback(feedback_incorrect)

    return e

Exercise.write_multiple(generator, 10, "manual_classify_digit")
```

```python
from matrix_tools import *
m = """
Compare the unclassified digit $U$ on the left to the digit $T$ on the right being classified as a $@label$.
Is the unclassified digit also a $@label$?
Answer $y$ for **yes** and $n$ for **no**.

<div class="d-flex flex-1 flex-items-center flex-justify-center">
$U = $

$, T = $

</div>
"""

feedback_correct = """
Indeed!
Think for a moment about how you compared the digits.
If you would devise a systematic approach to comparing digits, how would it look like?
"""

feedback_incorrect = """
It can be difficult to determine the similarity due to the low resolution or imperfect drawing.
Give it another try!
"""

def generator():
    i1 = np.random.randint(350)
    i2 = np.random.randint(350)
    a = nums[i1]
    b = nums[i2]
    matrix_to_image(to_binary(a), "a", False, False, False, False)
    matrix_to_image(to_binary(b), "b", False, False, False, False)
    params = {}
    params["label"] = targets[i2]
    e = Exercise(MarkdownBlock(m, params))

    same_class = sp.Symbol("y") if targets[i1] == targets[i2] else sp.Symbol("n")
    e.add_answer(same_class, True, feedback_correct)
    e.add_default_feedback(feedback_incorrect)

    return e

Exercise.write_multiple(generator, 10, "manual_same_class")
```

```python
data = [nums[7], nums[9], nums[10], nums[11],
        nums[1700], nums[1701], nums[1702], nums[1703]]

data_exercise = [nums[12], nums[21], nums[22],
        nums[1702], nums[1718], nums[1720]]

fix, axes = plt.subplots(nrows=3, ncols=len(data)+1, figsize=(20,5))

axes[0][0].axis("off")

for i in range(1, len(data)+1):
    axes[0][i].axis("off")
    axes[0][i].matshow(to_binary(data[i-1]), cmap="gray_r", vmin=0, vmax=1)

# for j in range(1, len(data)+1):
#     axes[j][0].axis("off")
#     axes[j][0].matshow(to_binary(data[j-1]), cmap="gray_r", vmin=0, vmax=1)

axes[1][0].axis("off")
axes[1][0].matshow(to_binary(nums[5]), cmap="gray_r", vmin=0, vmax=1)

axes[2][0].axis("off")

for i in range(1, 2):
    for j in range(1, len(data)+1):
#         print("{},{}".format(i, j))
        i0 = to_binary(nums[5])
        i1 = to_binary(data[j-1])
        similarity_matrix = sp.Matrix(np.abs(i0 - i1))
        numpy_sim_matrix = np.array(similarity_matrix).astype(np.int32)
#         axes[i][j].axis('off')
        show_grid(axes[i][j])
        axes[i][j].matshow(numpy_sim_matrix, cmap="gray_r", vmin=0, vmax=1)
        draw_numberline(numpy_sim_matrix.sum(), axes[i+1][j])

plt.savefig("neighbours_text.png", dpi=500, bbox_inches='tight')
```

```python
data = [nums[7], nums[9], nums[10], nums[11],
        nums[807], nums[802], nums[803], nums[840],
        nums[1700], nums[1701], nums[1702], nums[1703]]

fix, axes = plt.subplots(nrows=len(data)+1, ncols=len(data)+1, figsize=(12,12))

axes[0][0].axis("off")

for i in range(1, len(data)+1):
    axes[0][i].axis("off")
    axes[0][i].matshow(to_binary(data[i-1]), cmap="gray_r", vmin=0, vmax=1)

for j in range(1, len(data)+1):
    axes[j][0].axis("off")
    axes[j][0].matshow(to_binary(data[j-1]), cmap="gray_r", vmin=0, vmax=1)

for i in range(1, len(data)+1):
    for j in range(1, len(data)+1):
#         print("{},{}".format(i, j))
        i0 = to_binary(data[i-1])
        i1 = to_binary(data[j-1])
        similarity_matrix = sp.Matrix(np.abs(i0 - i1))
        numpy_sim_matrix = np.array(similarity_matrix).astype(np.int32)
        axes[i][j].axis('off')
        axes[i][j].matshow(numpy_sim_matrix, cmap="gray_r", vmin=0, vmax=1)

plt.savefig("cross.png", dpi=500, bbox_inches='tight')
```

```python

```

```python
def draw_numberline(score, axis):
    axis.set_xlim(0,10)
    axis.set_ylim(0,10)

    # draw lines
    xmin = 0
    xmax = 10
    y = 5
    height = 1

    axis.axis("off")
    axis.hlines(y, xmin, xmax)
    axis.vlines(xmin, y - height / 2., y + height / 2.)
    axis.vlines(xmax, y - height / 2., y + height / 2.)

    adjusted_score = (score/64)*10

    # draw a point on the line
    axis.plot(adjusted_score, y, '|', ms = 15, mfc = 'r', label="2")

    axis.text(xmin, y + 0.8, '0', horizontalalignment='center')
    axis.text(adjusted_score, y - 4, str(score), horizontalalignment='center')
    axis.text(xmax, y + 0.8, '64', horizontalalignment='center')
```

```python
md_4 = """
### The KNN algorithm
How can we exploit this similarity metric to predict the digit value a new image $N$?
We can compute the similarity score between $N$ and all other images for which we know the digit number (label).
We then only consider the K images with the highest similarity score and take the majority vote of the labels for these images.
"""
```

```python
similarity_matrix = np.empty((len(nums), len(nums)))
for i in tqdm.tqdm(range(len(nums))):
    for j in range(len(nums)):
        similarity_matrix[i][j] =  np.linalg.norm(((to_binary(nums[i]) - to_binary(nums[j]))**2))
plt.matshow(similarity_matrix)
```

```python
sns.heatmap(similarity_matrix, annot = False)
```

```python
for key in digits:
    print(key)
```

    data
    target
    frame
    feature_names
    target_names
    images
    DESCR

```python
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    digits.data, digits.target, test_size=0.5, shuffle=True)

# sorted_indices = np.argsort(y_train)
# X_train = X_train[sorted_indices]
# similarity_matrix = np.empty((len(X_train), len(X_train)))
# for i in tqdm.tqdm(range(len(X_train))):
#     for j in range(len(X_train)):
#         similarity_matrix[i][j] = np.sum(np.abs(to_binary(X_train[i]) - to_binary(X_train[j])))
# h = sns.heatmap(similarity_matrix, annot = False)
# plt.tight_layout()
# h.get_figure().suptitle("Similarity scores in training data (ordered by digit)")
# h.get_figure().savefig("similarity_scores_matrix", dpi=300)

predicted = []
scores = []

for d in tqdm.tqdm(X_test):
    scores = []
    for x in X_train:
        scores.append(np.sum(np.abs(to_binary(d) - to_binary(x))))
    indices = np.argsort(np.array(scores))
    neighbours = y_train[indices]
    knn = neighbours[:20]
#     print(knn)
    prediction = np.argmax(np.bincount(knn))
#     print(prediction)
    predicted.append(prediction)

print(np.count_nonzero(np.array(predicted)-y_test))

```

    100%|██████████| 899/899 [00:11<00:00, 77.51it/s]

    70

```python
print(predicted[:10])
print(y_test[:10])

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_test, np.array(predicted), labels=[i for i in range(10)])
g = sns.heatmap(cm, annot=True, xticklabels=True, yticklabels=True, cmap="Blues")
g.set_yticklabels(g.get_yticklabels(), rotation = 0)
g.set_xlabel("predicted")
g.set_ylabel("actual")
g.get_figure().savefig("confusion_matrix", dpi=300)
```

```python
## Exercises should at least contain 1 correct answer!! (check before write, otherwise throw error)

e = """
$@a + @b$
"""
a = np.random.randint(0, 10)
b = np.random.randint(0, 10)

ans = a + b

params = {}
params["a"] = sp.simplify(a)
params["b"] = sp.simplify(b)

e = Exercise(MarkdownBlock(e, params))
e.add_answer(sp.simplify(ans), True, MarkdownBlock("Good job!"))
e.display()
print(e.markdown.html)
e.write()
e.publish()
```

```python
print(repr(latex(sp.Matrix(2,2,[1,1,1,1]))))
```

```python
e = """
Given matrix $M = @m$, provide the matrix:

$A = @a$
"""
data = np.random.randint(0,10,(3,3))

m = sp.Matrix(data)
a = sp.Matrix(2, 2, [symbols("m_{1,1}"), symbols("m_{1,3}"), symbols("m_{3,1}"), symbols("m_{3,3}")])
print(a)

params = {}
params["m"] = sp.simplify(m)
params["a"] = sp.simplify(a)

ans = sp.Matrix([[m[0,0], m[0,2]], [m[2,0], m[2,2]]])
print(ans)

## TODO: retry with different values?
## Show answer button?
## Clear input button?

e = Exercise(MarkdownBlock(e, params))
e.add_answer(sp.simplify(ans), True, MarkdownBlock("Good job!"))
e.display()
e.write()
e.publish()
```

```python
math = """
\begin{align}
\dot{x} & = \sigma(y-x) \\
\dot{y} & = \rho x - y - xz \\
\dot{z} & = -\beta z + xy
\end{align}
"""
import re
display(math)
display(HTMLMath(math))
display(Math(r"{}".format(math)))
```

$$(a+b)^{2} = a^{2}+2ab+b^{2}$$

```python

```


# 13 Linear Algebra – Students (1)
## Motivating problem: Two masses on three strings
Two masses $M_1$ and $M_2$ are hung from a horizontal rod with length $L$ in such a way that a rope of length $L_1$ connects the left end of the rod to $M_1$, a rope of length $L_2$ connects $M_1$ and $M_2$, and a rope of length $L_3$ connects $M_2$ to the right end of the rod. The system is at rest (in equilibrium under gravity).

Find the angles that the ropes make with the rod and the tension forces in the ropes.

In class we derived the equations that govern this problem – see [14_String_Problem_lecture_notes (PDF)](14_String_Problem_lecture_notes.pdf).

We can represent the problem as system of nine coupled non-linear equations:

$$
\mathbf{f}(\mathbf{x}) = 0
$$

### Summary of equations to be solved
Treat $\sin\theta_i$ and $\cos\theta_i$ together with $T_i$, $1\leq i \leq 3$, as unknowns that have to simultaneously fulfill the nine equations
\begin{align}
-T_1 \cos\theta_1 + T_2\cos\theta_2 &= 0\\
 T_1 \sin\theta_1 - T_2\sin\theta_2 - W_1 &= 0\\
                   -T_2\cos\theta_2 + T_3\cos\theta_3 &= 0\\
                    T_2\sin\theta_2 + T_3\sin\theta_3 - W_2 &= 0\\
 L_1\cos\theta_1 + L_2\cos\theta_2 + L_3\cos\theta_3 - L &= 0\\
-L_1\sin\theta_1 - L_2\sin\theta_2 + L_3\sin\theta_3  &= 0\\
\sin^2\theta_1 + \cos^2\theta_1 - 1 &= 0\\
\sin^2\theta_2 + \cos^2\theta_2 - 1 &= 0\\
\sin^2\theta_3 + \cos^2\theta_3 - 1 &= 0
\end{align}

Consider the nine equations a vector function $\mathbf{f}$ that takes a 9-vector $\mathbf{x}$ of the unknowns as argument:
\begin{align}
\mathbf{f}(\mathbf{x}) &= 0\\
\mathbf{x} &= \left(\begin{array}{c}
x_0 \\ x_1 \\ x_2 \\
x_3 \\ x_4 \\ x_5 \\
x_6 \\ x_7 \\ x_8
\end{array}\right)
 =
\left(\begin{array}{c}
\sin\theta_1 \\ \sin\theta_2 \\ \sin\theta_3 \\
\cos\theta_1 \\ \cos\theta_2 \\ \cos\theta_3 \\
T_1 \\ T_2 \\ T_3
\end{array}\right) \\
\mathbf{L} &= \left(\begin{array}{c}
L \\ L_1 \\ L_2 \\ L_3
\end{array}\right), \quad
\mathbf{W} = \left(\begin{array}{c}
W_1 \\ W_2
\end{array}\right)
\end{align}

In more detail:

\begin{align}
f_1(\mathbf{x}) &= -x_6 x_3 + x_7 x_4 &= 0\\
f_2(\mathbf{x}) &= x_6 x_0 - x_7 x_1 - W_1 & = 0\\
\dots\\
f_8(\mathbf{x}) &= x_2^2 + x_5^2 - 1 &=0
\end{align}

We generalize the *Newton-Raphson algorithm* from the [last lecture](http://asu-compmethodsphysics-phy494.github.io/ASU-PHY494//2018/03/21/12_Root_finding/) to $n$ dimensions:

## General Newton-Raphson algorithm
Given a trial vector $\mathbf{x}$, the correction $\Delta\mathbf{x}$ can be derived from the Taylor expansion
$$
f_i(\mathbf{x} + \Delta\mathbf{x}) = f_i(\mathbf{x}) + \sum_{j=1}^{n} \left.\frac{\partial f_i}{\partial x_j}\right|_{\mathbf{x}} \, \Delta x_j + \dots
$$
or in full vector notation
\begin{align}
\mathbf{f}(\mathbf{x} + \Delta\mathbf{x}) &= \mathbf{f}(\mathbf{x}) + \left.\frac{d\mathbf{f}}{d\mathbf{x}}\right|_{\mathbf{x}} \Delta\mathbf{x} + \dots\\
  &= \mathbf{f}(\mathbf{x}) + \mathsf{J}(\mathbf{x}) \Delta\mathbf{x} + \dots
\end{align}
where $\mathsf{J}(\mathbf{x})$ is the *[Jacobian](http://mathworld.wolfram.com/Jacobian.html)* matrix of $\mathbf{f}$ at $\mathbf{x}$, the generalization of the derivative to multivariate vector functions.

Solve
$$
\mathbf{f}(\mathbf{x} + \Delta\mathbf{x}) = 0
$$
i.e.,
$$
\mathsf{J}(\mathbf{x}) \Delta\mathbf{x} = -\mathbf{f}(\mathbf{x})
$$
for the correction $\Delta x$
$$
\Delta\mathbf{x} = -\mathsf{J}(\mathbf{x})^{-1} \mathbf{f}(\mathbf{x})
$$
which has the same form as the 1D Newton-Raphson correction $\Delta x = -f'(x)^{-1} f(x)$.

These are *matrix equations* (we linearized the problem). One can either explicitly solve for the unknown vector $\Delta\mathbf{x}$ with the inverse matrix of the Jacobian or use other methods to solve the coupled system of linear equations of the general form
$$
\mathsf{A} \mathbf{x} = \mathbf{b}.
$$

## Linear algebra with `numpy.linalg`

```python
import numpy as np
```

```python
np.linalg?
```

### System of coupled linear equations
Solve the coupled system of linear equations of the general form
$$
\mathsf{A} \mathbf{x} = \mathbf{b}.
$$

```python
A = np.array([
        [1, 0, 0],
        [0, 1, 0],
        [0, 0, 2]
    ])
b = np.array([1, 0, 1])
```

What does this system of equations look like?

```python
for i in range(A.shape[0]):
    terms = []
    for j in range(A.shape[1]):
        terms.append("{1} x[{0}]".format(i, A[i, j]))
    print(" + ".join(terms), "=", b[i])
```

    1 x[0] + 0 x[0] + 0 x[0] = 1
    0 x[1] + 1 x[1] + 0 x[1] = 0
    0 x[2] + 0 x[2] + 2 x[2] = 1

Now solve it with `numpy.linalg.solve`:

```python

```

Test that it satisfies the original equation:
$$
\mathsf{A} \mathbf{x} - \mathbf{b} = 0
$$

```python

```

#### Activity: Solving matrix equations
With
$$
\mathsf{A}_1 = \left(\begin{array}{ccc}
   +4 & -2 & +1\\
   +3 & +6 & -4\\
   +2 & +1 & +8
   \end{array}\right)
$$
and
$$
\mathbf{b}_1 = \left(\begin{array}{c}
   +12 \\ -25 \\ +32
   \end{array}\right), \quad
\mathbf{b}_2 = \left(\begin{array}{c}
   +4 \\ -1 \\ +36
   \end{array}\right), \quad
$$
solve for $\mathbf{x}_i$
$$
\mathsf{A}_1 \mathbf{x}_i = \mathbf{b}_i
$$
and *check the correctness of your answer*.

```python

```

### Matrix inverse
In order to solve directly we need the inverse of $\mathsf{A}$:
$$
\mathsf{A}\mathsf{A}^{-1} = \mathsf{A}^{-1}\mathsf{A} = \mathsf{1}
$$
Then
$$
\mathbf{x} = \mathsf{A}^{-1} \mathbf{b}
$$

If the inverse exists, `numpy.linalg.inv()` can calculate it:

```python

```

Check that it behaves like an inverse:

```python

```

Now solve the coupled equations directly:

```python

```

#### Activity: Solving coupled equations with the inverse matrix
1. Compute the inverse of $\mathsf{A}_1$ and *check the correctness*.
2. Compute $\mathbf{x}_1$ and $\mathbf{x}_2$ with $\mathsf{A}_1^{-1}$ and check the correctness of your answers.

```python

```

### Eigenvalue problems
The equation
\begin{gather}
\mathsf{A} \mathbf{x}_i = \lambda_i \mathbf{x}_i
\end{gather}
is the **eigenvalue problem** and a solution provides the eigenvalues $\lambda_i$ and corresponding eigenvectors $x_i$ that satisfy the equation.

#### Example 1: Principal axes of a square
The principle axes of the [moment of inertia tensor](https://en.wikipedia.org/wiki/Moment_of_inertia#The_inertia_tensor) are defined through the eigenvalue problem
$$
\mathsf{I} \mathbf{\omega}_i = \lambda_i \mathbf{\omega}_i
$$
The principal axes are the $\mathbf{\omega}_i$.

```python
Isquare = np.array([[2/3, -1/4], [-1/4, 2/3]])
```

```python

```

Note that the eigenvectors are `omegas[:, i]`! You can transpose so that axis 0 is the eigenvector index:

```python

```

Test:
$$
(\mathsf{I} - \lambda_i \mathsf{1}) \mathbf{\omega}_i = 0
$$
(The identity matrix can be generated with `np.identity(2)`.)

```python

```

#### Example 2: Spin in a magnetic field
In quantum mechanics, a spin 1/2 particle is represented by a spinor $\chi$, a 2-component vector. The Hamiltonian operator for a stationary spin 1/2 particle in a homogenous magnetic field $B_y$ is
$$
\mathsf{H} = -\gamma \mathsf{S}_y B_y = -\gamma B_y \frac{\hbar}{2} \mathsf{\sigma_y}
   = \hbar \omega \left( \begin{array}{cc} 0 & -i \\ i & 0 \end{array}\right)
$$

Determine the *eigenvalues* and *eigenstates*
$$
\mathsf{H} \mathbf{\chi} = E \mathbf{\chi}
$$
of the spin 1/2 particle.

(To make this a purely numerical problem, divide through by $\hbar\omega$, i.e. calculate $E/\hbar\omega$.)

```python

```

Normalize the eigenvectors:
$$
\hat\chi = \frac{1}{\sqrt{\chi^\dagger \cdot \chi}} \chi
$$

```python

```

#### Activity: eigenvalues
Find the eigenvalues and eigenvectors of
$$
\mathsf{A}_2 = \left(\begin{array}{ccc}
   -2 & +2 & -3\\
   +2 & +1 & -6\\
   -1 & -2 & +0
   \end{array}\right)
$$

Are the eigenvectors normalized?

Check your results.

```python

```


Exercice 1 :

```python
import numpy as np
import sympy as sm
sm.init_printing(use_unicode=True)
```

```python
rho = sm.symbols('rho')
beta = sm.symbols('beta')
gamma = sm.symbols('gamma')
w = sm.symbols('w')
b = sm.symbols('b')
delta = sm.symbols('Delta')
h1 = sm.symbols('h_1')
h2 = sm.symbols('h_2')
c1 = sm.symbols('c1')
c2 = sm.symbols('c2')
l1 = sm.symbols('l1')
l2 = sm.symbols('l2')
exp = sm.symbols('E[v_2(h_2)]')
```

```python
u2 = (c2**(1-rho))/(1-rho)
dis_u2 = gamma*l2
total_u2 = u2 - dis_u2
h2_f = (h1 + l1)
c2_f = w*h2*l2
total_u2
```

```python
total_u2_sub2 = total_u2.subs(c2,c2_f)
foc2 = sm.diff(total_u2_sub2,l2)
foc2
```

```python
sol2 = sm.solve(sm.Eq(foc2,0),l2)[0]
sol2
```

```python
def utility_ahc(h2,w,gamma,rho):
    solution = ((h2*w)/gamma)**(1/rho)/(h2*w)
    return solution
```

```python
utility_ahc(1, 2, 0.1, 2)
```

```python
#graph
x = np.linspace(0.1, 100,100);
utility_ahc(x,2,0.1,2)
```

    array([7.07106781, 2.12325145, 1.53639677, 1.26445135, 1.09945041,
           0.9857644 , 0.90133661, 0.83544572, 0.78217062, 0.73793933,
           0.70045226, 0.6681531 , 0.63994601, 0.61503402, 0.59282181,
           0.57285451, 0.55477779, 0.53831098, 0.52322848, 0.5093466 ,
           0.49651407, 0.48460501, 0.47351372, 0.46315071, 0.45343961,
           0.44431481, 0.4357196 , 0.42760466, 0.41992686, 0.41264832,
           0.40573559, 0.39915902, 0.39289222, 0.38691162, 0.38119606,
           0.37572654, 0.37048589, 0.36545859, 0.36063054, 0.35598892,
           0.35152203, 0.34721918, 0.34307056, 0.33906718, 0.33520076,
           0.33146366, 0.32784883, 0.32434974, 0.32096035, 0.31767504,
           0.31448859, 0.31139615, 0.30839317, 0.30547543, 0.30263896,
           0.29988007, 0.29719528, 0.29458134, 0.29203517, 0.2895539 ,
           0.28713483, 0.28477539, 0.28247317, 0.2802259 , 0.27803143,
           0.27588772, 0.27379284, 0.27174497, 0.26974237, 0.26778341,
           0.26586651, 0.2639902 , 0.26215306, 0.26035375, 0.25859099,
           0.25686356, 0.25517028, 0.25351006, 0.25188183, 0.25028458,
           0.24871733, 0.24717915, 0.24566917, 0.24418653, 0.24273041,
           0.24130003, 0.23989464, 0.23851353, 0.237156  , 0.23582139,
           0.23450906, 0.2332184 , 0.23194882, 0.23069975, 0.22947064,
           0.22826097, 0.22707023, 0.22589793, 0.22474361, 0.2236068 ])

Question 2 :

We assume the agents are perfectly rationnal, and therefore they should anticipate their utility for the next period, so we can assume that E[u2(h2)] is equal to utility that we find for the first period.

```python

u1 = (c1**(1-rho))/(1-rho) + beta*total_u2_sub2
dis_u1 = gamma*l1
total_u1 = u1 - dis_u1
h2_f = (h1 + l1 + delta)
c1_f = w*h1*l1
#

total_u1
```

```python
total_u1_sub1 = total_u1.subs(c1,c1_f)
total_u1_sub12 = total_u1_sub1.subs(l2,sol2)
total_u1_sub1_r = total_u1_sub12.subs(h2,h2_f)
total_u1_sub1_r
```

```python
foc1 = sm.diff(total_u1_sub1_r,l1)
foc1
```

```python
#foc1_minus_l2 = foc1.subs(l2,sol2)
#foc1_minus_l2
```

```python
solb2 = sm.solve(sm.Eq(foc1,0),l1)[0]
solb2
```

```python
def utility_ahc(delta, h1, beta, l2, w, gamma, rho):
    stochastic =  random.randint(0,1)
    if stochastic > 0 :
        solution = -h1 + (((beta*l2*w)/gamma)**(1/p))/(l2*w)
    else :
        solution = -delta -h1 + (((beta*l2*w)/gamma)**(1/p))/(l2*w)
    return solution
```

```python
utility_ahc(0.1,1,0.96,)
```

```python

```

Exercice 2 :

Question 1 :

```python
alpha = sm.symbols('alpha')
h = sm.symbols('h')
lam = sm.symbols('lambda')
pi = sm.symbols('pi_t')
v = sm.symbols('v_t')
s = sm.symbols('s_t')
y = sm.symbols('y_t')
pi
```

```python

```

```python

```


# Intro to Quantum Computing and Quantum Logic Gates

This is an introductory talk on Quantum Computing made for QPower, so it is aimed towards a public with a considerable amount of knowledge, both in programming and in math (you MUST at least know how to multiply matrices to read this). For this reason, I opted for a more hands-on, visual approach.

It will just cover gate quantum computation model - there will be no annealing discussed here.

I will be covering two initial topics: _general concepts_ and _quantum logic gates_. This will be done using the Qiskit (it is pronounced "quiskit", according to their website) library within a Python language environment.

Since I will be resorting to visual aid, in case you haven't a good knowledge of the Python language, it shouldn't be something that will impact the general knowledge of the concepts/ideas behind this tutorial.

The output of the cell below may be considerably big - you can just ignore it and scroll below for the main content.

```
# Installation of the Qiskit library
!pip install qiskit pylatexenc
```

    Requirement already satisfied: qiskit in /usr/local/lib/python3.6/dist-packages (0.21.0)
    Collecting pylatexenc
      Downloading https://files.pythonhosted.org/packages/97/68/6bbd9841e8fb2fb977594f7b8158a26dfd4016e078c865481848a4f0a7b1/pylatexenc-2.7.tar.gz (157kB)
         |████████████████████████████████| 163kB 3.5MB/s
    Requirement already satisfied: qiskit-aqua==0.7.5 in /usr/local/lib/python3.6/dist-packages (from qiskit) (0.7.5)
    Requirement already satisfied: qiskit-aer==0.6.1 in /usr/local/lib/python3.6/dist-packages (from qiskit) (0.6.1)
    Requirement already satisfied: qiskit-ignis==0.4.0 in /usr/local/lib/python3.6/dist-packages (from qiskit) (0.4.0)
    Requirement already satisfied: qiskit-ibmq-provider==0.9.0 in /usr/local/lib/python3.6/dist-packages (from qiskit) (0.9.0)
    Requirement already satisfied: qiskit-terra==0.15.2 in /usr/local/lib/python3.6/dist-packages (from qiskit) (0.15.2)
    Requirement already satisfied: fastdtw in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (0.3.4)
    Requirement already satisfied: setuptools>=40.1.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (50.3.0)
    Requirement already satisfied: scipy>=1.4 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (1.4.1)
    Requirement already satisfied: quandl in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (3.5.2)
    Requirement already satisfied: yfinance in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (0.1.54)
    Requirement already satisfied: scikit-learn>=0.20.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (0.22.2.post1)
    Requirement already satisfied: dlx in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (1.0.4)
    Requirement already satisfied: numpy>=1.17 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (1.18.5)
    Requirement already satisfied: docplex in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (2.15.194)
    Requirement already satisfied: psutil>=5 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (5.4.8)
    Requirement already satisfied: pandas in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (1.0.5)
    Requirement already satisfied: sympy>=1.3 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (1.6.2)
    Requirement already satisfied: networkx>=2.2 in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (2.5)
    Requirement already satisfied: h5py in /usr/local/lib/python3.6/dist-packages (from qiskit-aqua==0.7.5->qiskit) (2.10.0)
    Requirement already satisfied: pybind11>=2.4 in /usr/local/lib/python3.6/dist-packages (from qiskit-aer==0.6.1->qiskit) (2.5.0)
    Requirement already satisfied: cython>=0.27.1 in /usr/local/lib/python3.6/dist-packages (from qiskit-aer==0.6.1->qiskit) (0.29.21)
    Requirement already satisfied: requests>=2.19 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.9.0->qiskit) (2.23.0)
    Requirement already satisfied: python-dateutil>=2.8.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.9.0->qiskit) (2.8.1)
    Requirement already satisfied: urllib3>=1.21.1 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.9.0->qiskit) (1.24.3)
    Requirement already satisfied: nest-asyncio!=1.1.0,>=1.0.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.9.0->qiskit) (1.4.0)
    Requirement already satisfied: requests-ntlm>=1.1.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.9.0->qiskit) (1.1.0)
    Requirement already satisfied: websockets<8,>=7; python_version <= "3.6" in /usr/local/lib/python3.6/dist-packages (from qiskit-ibmq-provider==0.9.0->qiskit) (7.0)
    Requirement already satisfied: dill>=0.3 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (0.3.2)
    Requirement already satisfied: jsonschema>=2.6 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (2.6.0)
    Requirement already satisfied: fastjsonschema>=2.10 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (2.14.5)
    Requirement already satisfied: contextvars>=2.4; python_version < "3.7" in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (2.4)
    Requirement already satisfied: retworkx>=0.4.0 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (0.5.0)
    Requirement already satisfied: ply>=3.10 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (3.11)
    Requirement already satisfied: python-constraint>=1.4 in /usr/local/lib/python3.6/dist-packages (from qiskit-terra==0.15.2->qiskit) (1.4.0)
    Requirement already satisfied: more-itertools in /usr/local/lib/python3.6/dist-packages (from quandl->qiskit-aqua==0.7.5->qiskit) (8.5.0)
    Requirement already satisfied: six in /usr/local/lib/python3.6/dist-packages (from quandl->qiskit-aqua==0.7.5->qiskit) (1.15.0)
    Requirement already satisfied: inflection>=0.3.1 in /usr/local/lib/python3.6/dist-packages (from quandl->qiskit-aqua==0.7.5->qiskit) (0.5.1)
    Requirement already satisfied: multitasking>=0.0.7 in /usr/local/lib/python3.6/dist-packages (from yfinance->qiskit-aqua==0.7.5->qiskit) (0.0.9)
    Requirement already satisfied: joblib>=0.11 in /usr/local/lib/python3.6/dist-packages (from scikit-learn>=0.20.0->qiskit-aqua==0.7.5->qiskit) (0.16.0)
    Requirement already satisfied: pytz>=2017.2 in /usr/local/lib/python3.6/dist-packages (from pandas->qiskit-aqua==0.7.5->qiskit) (2018.9)
    Requirement already satisfied: mpmath>=0.19 in /usr/local/lib/python3.6/dist-packages (from sympy>=1.3->qiskit-aqua==0.7.5->qiskit) (1.1.0)
    Requirement already satisfied: decorator>=4.3.0 in /usr/local/lib/python3.6/dist-packages (from networkx>=2.2->qiskit-aqua==0.7.5->qiskit) (4.4.2)
    Requirement already satisfied: idna<3,>=2.5 in /usr/local/lib/python3.6/dist-packages (from requests>=2.19->qiskit-ibmq-provider==0.9.0->qiskit) (2.10)
    Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.6/dist-packages (from requests>=2.19->qiskit-ibmq-provider==0.9.0->qiskit) (2020.6.20)
    Requirement already satisfied: chardet<4,>=3.0.2 in /usr/local/lib/python3.6/dist-packages (from requests>=2.19->qiskit-ibmq-provider==0.9.0->qiskit) (3.0.4)
    Requirement already satisfied: ntlm-auth>=1.0.2 in /usr/local/lib/python3.6/dist-packages (from requests-ntlm>=1.1.0->qiskit-ibmq-provider==0.9.0->qiskit) (1.5.0)
    Requirement already satisfied: cryptography>=1.3 in /usr/local/lib/python3.6/dist-packages (from requests-ntlm>=1.1.0->qiskit-ibmq-provider==0.9.0->qiskit) (3.1)
    Requirement already satisfied: immutables>=0.9 in /usr/local/lib/python3.6/dist-packages (from contextvars>=2.4; python_version < "3.7"->qiskit-terra==0.15.2->qiskit) (0.14)
    Requirement already satisfied: cffi!=1.11.3,>=1.8 in /usr/local/lib/python3.6/dist-packages (from cryptography>=1.3->requests-ntlm>=1.1.0->qiskit-ibmq-provider==0.9.0->qiskit) (1.14.2)
    Requirement already satisfied: pycparser in /usr/local/lib/python3.6/dist-packages (from cffi!=1.11.3,>=1.8->cryptography>=1.3->requests-ntlm>=1.1.0->qiskit-ibmq-provider==0.9.0->qiskit) (2.20)
    Building wheels for collected packages: pylatexenc
      Building wheel for pylatexenc (setup.py) ... done
      Created wheel for pylatexenc: filename=pylatexenc-2.7-cp36-none-any.whl size=130764 sha256=98af51ead991ae3f65b34c49b13cc4337ba86c249b05bd5ed064073587e6b275
      Stored in directory: /root/.cache/pip/wheels/d3/e6/ca/261bd01506ba7e835ed21bfec202389cf670198b87cde58a84
    Successfully built pylatexenc
    Installing collected packages: pylatexenc
    Successfully installed pylatexenc-2.7

```
import numpy as np
import matplotlib.pyplot as plt
from math import sqrt
from qiskit import *
from qiskit.extensions import Initialize
```

## Computation and Information

The whole idea behind computers is totally related to the theory of information. Both fields walk together, and are very much intertwined in their progress.

So, when talking about both quantum and classical computing, we have to start from this basic standpoint: we are dealing with different ways of processing information. In this sense, we have to define what _processing_ means:

$$\text{Input } \Longrightarrow \text{ Output}$$

When we grasp any amount of information, and handle it in any way whatsoever, with some sort of information being obtained as a result, we are _processing_ this information, or we could even say _computing_ it. In here, we will not consider notions such as transmitters, receivers and noise sources - the idea is just to understand what is meant by computing/processing information in general.

But how does that helps in understanding how classical and quantum computing are different?

The first difference is in the fundamental unit of information. Without diving too deep in Information Theory [(see this link to know how it all began)](http://people.math.harvard.edu/~ctm/home/text/others/shannon/entropy/entropy.pdf), let's talk a little bit about both fundamental units - the _bit_ and the _qubit_.

## Bits and Qubits

A _bit_ is the fundamental unit of information within the classical computing environment. It represents an entity capable of store two stable positions, like a relay. On and off, 0 or 1, current or no current. In vector notation:

_(Note: if you know how to write Dirac notation here, please let me know!)_

$$1 = \begin{pmatrix} 0\\1 \end{pmatrix}\space and\space\space 0 = \begin{pmatrix} 1\\0 \end{pmatrix} $$

```
zero = np.matrix('1;0')
one = np.matrix('0;1')
```

### Four basic operations with bits

With _bits_, there are four possible basic operations that can be done:

$$\text{Identity:}\space\space {f}(x) = x$$

$$\text{Negation:}\space\space {f}(x) = \neg x$$

$$\text{Constant-0:}\space\space {f}(x) = 0$$

$$\text{Constant-1:}\space\space {f}(x) = 1$$

Using the matrix notation:

```
# Identity:
identity = np.matrix('1 0; 0 1')
print('The identity matrix:')
print(identity,'\n')

# Multiplying zero by the identity matrix returns zero,
result = identity @ zero
print(result, '\n')

# And multiplying one by the identity matrix returns one, as expected.
result = identity @ one
print(result)
```

    The identity matrix:
    [[1 0]
     [0 1]]

    [[1]
     [0]]

    [[0]
     [1]]

```
# Negation:
neg = np.matrix('0 1; 1 0')
print('The negation matrix:')
print(neg, '\n')

# Multiplying zero by the negation matrix will return one,
result = neg @ zero
print(result, '\n')

# And multiplying one by the negation matrix will return zero, as expected.
result = neg @ one
print(result)
```

    The negation matrix:
    [[0 1]
     [1 0]]

    [[0]
     [1]]

    [[1]
     [0]]

```
# Constant-0:
c_zero = np.matrix('1 1; 0 0')
print('The Constant-0 matrix:')
print(c_zero, '\n')

# Multiplying zero by the Constant-0 matrix will return zero,
result = c_zero @ zero
print(result, '\n')

# And multiplying one by the Constant-0 matrix will also return zero, as expected.
result = c_zero @ one
print(result)
```

    The Constant-0 matrix:
    [[1 1]
     [0 0]]

    [[1]
     [0]]

    [[1]
     [0]]

```
# Constant-1:
c_one = np.matrix('0 0; 1 1')
print('The Constant-1 matrix:')
print(c_one, '\n')

# Multiplying zero by the Constant-1 matrix will return one,
result = c_one @ zero
print(result, '\n')

# And multiplying one by the Constant-1 matrix will also return one, as expected.
result = c_one @ one
print(result)
```

    The Constant-1 matrix:
    [[0 0]
     [1 1]]

    [[0]
     [1]]

    [[0]
     [1]]

### Reversible operations with bits

For the purposes of our explanation here, we will define _reversible operations_ as **any _process_** (cf. the aforementioned definition of processing) **that allows the identification of the input just by knowing the operation used and the output**.

E.g.: In our operations above, identity and negation are reversible, but constant-0 and constant-1 are not.

So, we can have operations with bits that aren't reversible, and that isn't an issue.

### Tensor Products

In order to fully understand some of the next concepts, it is necessary to explain a concept that is not always taught in the regular Linear Algebra classes: Tensor Products [(for further explanation, click here)](https://www.grc.nasa.gov/www/k-12/Numbers/Math/documents/Tensors_TM2002211716.pdf).

These examples will suffice to explain how tensor products work:

$$\begin{pmatrix} x_{0} \\ x_{1} \end{pmatrix} \otimes \begin{pmatrix} y_{0} \\ y_{1} \end{pmatrix} = \begin{pmatrix} x_{0}y_{0} \\ x_{0}y_{1} \\ x_{1}y_{0} \\ x_{1}y_{1} \end{pmatrix} $$

Another example:

$$\begin{pmatrix} x_{0} \\ x_{1} \end{pmatrix} \otimes \begin{pmatrix} y_{0} \\ y_{1} \end{pmatrix} \otimes \begin{pmatrix} z_{0} \\ z_{1} \end{pmatrix} = \begin{pmatrix} x_{0}y_{0}z_{0} \\ x_{0}y_{0}z_{1} \\ x_{0}y_{1}z_{0} \\ x_{0}y_{1}z_{1} \\ x_{1}y_{0}z_{0} \\ x_{1}y_{0}z_{1} \\ x_{1}y_{1}z_{0} \\ x_{1}y_{1}z_{1} \end{pmatrix} $$

### Representing multiple bits

To represent multiple bit states, we use tensor products of single-bit vectors. Again, this is a simplified explanation, but this will be helpful in understanding operations with multiple bits, and it will be very useful to show the power of quantum computing.

The tensored representation of multiple bits is called _product state_. This representation is very useful, because it allows factoring the product state into the individual state representations.

Let's use as an example the product states for two bits:

$$\text{We have four possible states for two bits:}$$

$$00, 01, 10\space or\space 11$$:

$$\begin{pmatrix} 1 \\ 0 \end{pmatrix} \otimes \begin{pmatrix} 1 \\ 0 \end{pmatrix} = \begin{pmatrix} 1 \\ 0 \\ 0 \\ 0 \end{pmatrix} \space\space\text{0 tensored with 0,}$$

$$\begin{pmatrix} 1 \\ 0 \end{pmatrix} \otimes \begin{pmatrix} 0 \\ 1 \end{pmatrix} = \begin{pmatrix} 0 \\ 1 \\ 0 \\ 0 \end{pmatrix} \space\space\text{0 tensored with 1,}$$

$$\begin{pmatrix} 0 \\ 1 \end{pmatrix} \otimes \begin{pmatrix} 1 \\ 0 \end{pmatrix} = \begin{pmatrix} 0 \\ 0 \\ 1 \\ 0 \end{pmatrix} \space\space\text{1 tensored with 0,}$$

$$\begin{pmatrix} 0 \\ 1 \end{pmatrix} \otimes \begin{pmatrix} 0 \\ 1 \end{pmatrix} = \begin{pmatrix} 0 \\ 0 \\ 0 \\ 1 \end{pmatrix} \space\space\text{and 1 tensored with 1}$$

```
# Displaying the same thing using NumPy:

zero_zero = np.kron(zero, zero)
zero_one = np.kron(zero, one)
one_zero = np.kron(one, zero)
one_one = np.kron(one, one)
print(zero_zero,'\n\n', zero_one,'\n\n', one_zero,'\n\n', one_one)
```

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

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

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

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

So, if we have _2_ bytes, there are four possible _product states_.

Therefore, the number of possible _product states_ with _n_ bytes will be $ 2^{n}$.

### An operation with multiple bits: CNOT

Now let's talk about one of the fundamental building blocks of reversible computing: the CNOT operation.

The Control-Not operation works in pairs of _bits_; one is assigned as _control_, and the other as _target_.

The way this _gate_(we'll call these operations gates from now on) behaves is the following:

- If the control bit is 1, then the target gets flipped
- If the control bit is 0, then the target remains unchanged
- The control bit never gets changed.
- The most-significant bit (the one on the left) is control, and the other one is the target;

Example:

```
cnot = np.matrix('1 0 0 0; 0 1 0 0; 0 0 0 1; 0 0 1 0')
print(cnot)
```

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

```
# CNOT on 00:
result = cnot @ zero_zero
print('Since the control bit is zero, the target bit remains unchanged here,\n')
print(result, '\n')

# CNOT on 01
result = cnot @ zero_one
print('and here as well.\n')
print(result)
```

    Since the control bit is zero, the target bit remains unchanged here,

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

    and here as well.

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

```
# CNOT on 10:
result = cnot @ one_zero
print('However, it gets flipped both here,\n')
print(result, '\n')

# CNOT on 11
result = cnot @ one_one
print('and here as well.\n')
print(result)
```

    However, it gets flipped both here,

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

    and here as well.

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

### Quantum fundamental units: _qubits_

The first thing worth mentioning here is that all operations with quantum bits have to be reversible - and they are. They are also their own reverse - in other words, if you apply the same operation twice, you'll get the initial result.

We can go even further here: quantum mechanics _is_ reversible, and more specifically, it is _unitary_ - this follows from Schröddinger equation, except the measurement, which is not an operation/gate (if this doesn't make sense for you, don't worry: focus on the mathematics below and you should be fine).

Guess what? Everything we've displayed for classical bits, so far, is similar to what we'll do for qubits! We can even boldly say that the classical bit vectors above are special cases of qubit vectors.

 **Definition:**

 A qubit is represented by a vector $\begin{pmatrix} a \\ b \end{pmatrix} : \space a,b \in \mathbb{C}\space\space, \lVert a\rVert^{2} + \lVert b\rVert^{2} = 1$

Examples: $\begin{pmatrix} -1 \\ 0 \end{pmatrix}, \begin{pmatrix} \frac12 \\ \frac{\sqrt3}{2} \end{pmatrix}, \begin{pmatrix} \frac{1}{\sqrt2} \\ \frac{1}{\sqrt2} \end{pmatrix}, \begin{pmatrix} \frac{1}{\sqrt2} \\ \frac{-1}{\sqrt2} \end{pmatrix}$

But with this, a question then arises: if we were considering a bit the unit capable of being either "there or not there" (two possible values), how come we see values different than 0 or 1 in a qubit?

I'll just go ahead and say that in such cases, they're being both values at the same time!

### Superposition - Math explanation

It is time to go ahead and start facing the complicated definitions involved with qubits.

Instead of having to tackle the physical explanation of a qubit being able to be both 0 and 1 at the same time (which is actually true), let's try to stick to the math here - it is actually easier to understand this way.

An important thing to understand regarding qubits is that whenever they're measured, they'll _collapse_ to a value of either 0 or 1.

So, the good thing about the vector notation can be seen here: the probability of a qubit $\begin{pmatrix} a \\ b  \end{pmatrix}$ to collapse to 0 is $\lVert a\rVert^{2}$ and to 1, $\lVert b\rVert^{2}$

This definition will help us to better understand the behavior of a multiple qubit system.

### Multiple qubits

The representation of a system with multiple qubits is similar to what we've used for classical bits: it consists of a tensor product of single qubits.

```
## Example using NumPy:

# Let's take a qubit (1/sqrt(2), 1/sqrt(2)):
qubit = np.array([[1/sqrt(2)],[1/sqrt(2)]])

# Its product state is the tensor product of it by itself:
product_state = np.kron(qubit, qubit)
print(product_state)
# Note that for the resulting product state, the sum of the squares of the
# moduli will still be 1.
```

    [[0.5]
     [0.5]
     [0.5]
     [0.5]]

Looking to the product state above, the system has a $\lVert\frac12\rVert^{2}= \frac14 $ probability (we call this amplitude) each of collapsing to 00, 01, 10 or 11 states after being measured.

## Quantum Gates (operations with qubits)

All the operations (gates) described above for bits work for qubits, with a fundamental difference: as we've stated before, they have to be reversible.

Let's start with the most important gate - the Hadamard gate.

### Hadamard

_From this point forward, we'll start using the Qiskit library in order to be able to display the circuit notation as well._

The Hadamard gate (H-gate) takes a 0 or 1 qubit and puts it into exactly equal superposition:

$$ H = \begin{pmatrix} \frac1{\sqrt2} \ \frac1{\sqrt2} \\ \frac1{\sqrt2} \ \frac{-1}{\sqrt2} \end{pmatrix}$$

```
hadamard = np.array([[1/sqrt(2),1/sqrt(2)],[1/sqrt(2),-1/sqrt(2)]])

# Applying Hadamard to qubit 0,
result = hadamard @ zero
print(result, '\n')

# and to qubit 1:
result = hadamard @ one
print(result)
```

    [[0.70710678]
     [0.70710678]]

    [[ 0.70710678]
     [-0.70710678]]

This result shows qubit vectors in superposition, with an equal probability of collapsing into either 0 or 1.

**Question**
What happens if we apply the H-gate to one of the resulting qubits in superposition above?

### Circuit notation

One of the notations we use to represent a sequence of operations with qubits is the circuit notation.

A significant advantage of the Qiskit library is to allow us to visualize these operations using this notation, as demonstrated below:

```

def challengeTwo(qc, qubit):
  qc.x(qubit)
  qc.h(qubit)
  qc.x(qubit)
  qc.h(qubit)
  qc.x(qubit)
  return qc

quantum_circuit = QuantumCircuit(1)
initial_state = [1,0]
initializer = Initialize(initial_state)
initializer.label = "init"
challengeTwo(quantum_circuit, 0)
quantum_circuit.draw()

```

     ┌───┐┌───┐┌───┐┌───┐┌───┐
q_0: ┤ X ├┤ H ├┤ X ├┤ H ├┤ X ├
     └───┘└───┘└───┘└───┘└───┘

This diagram above exhibits a sequence of operations that are performed within a given circuit (thus the name circuit notation).

Particularly, in this case, from an input of a qubit 0, the following operations are being applied:

1.   A Pauli-X gate (think of the negation operation for a bit)
2.   A Hadamard gate
3.   Another Pauli-X
4.   Another Hadamard
5.   Another Pauli-X

**Question** Can you determine the outcome if the qubit is measured after these five operations?

### Pauli

If you're somewhat familiar with Linear Algebra, you probably have heard about the Pauli matrices - a set of three 2x2 matrices that are both Hermitian and unitary (you can totally ignore that if you didn't get it, that's fine as well).

Let's start with the one that was mentioned in the previous section, the Pauli-X gate (and it's totally fine to just refer to it as X gate):

####Pauli X

$$ X = \begin{pmatrix} 0 \ 1 \\ 1 \ 0 \end{pmatrix}$$

It is the same matrix used for the negation gate in a bit, right? So, let's observe it with a qubit:

```
from qiskit.visualization import plot_bloch_multivector

# Let's design a circuit with a single qubit initialized in the
# 0 state and apply an X-gate to it:

qc = QuantumCircuit(1)
qc.x(0)

# This sphere below is called the Bloch sphere, and it is used to
# represent qubits. As seen below, that's where the qubit "lands at"
# after passing through the circuit:
backend = Aer.get_backend('statevector_simulator')
output = execute(qc, backend).result().get_statevector()
plot_bloch_multivector(output)
```

As expected, the resulting state is 1, as expected: a rotation of $\pi$ radians around the _x_ axis of the sphere.

You'll also see this gate being called as the _NOT_-gate.

#### Pauli Y

Similarly to the X-gate, this one will rotate a qubit $\pi$ radians around the _y_ axis of the sphere:

$$ Y = \begin{pmatrix} 0 \ -i \\ i \ 0 \end{pmatrix}$$

#### Pauli Z

As you've probably guessed, the Z-gate rotates a qubit $\pi$ radians around the _z_ axis:

$$ Z = \begin{pmatrix} 1 \ 0 \\ 0 \ {-1} \end{pmatrix}$$

### $R_\phi$ (or $R_z$)

The $R_\phi$ gate is a parametrized gate that performs a rotation of $\phi$ degrees along the Z-axis:

$$ R_\phi = \begin{pmatrix} 1 \ 0 \\ 0 \ e^{i\phi} \end{pmatrix} , with \space\space \phi \in \mathbb R $$

### Identity

The I-gate (Identity) does exactly what's expected of it: nothing. Still, it has some applications that won't be discussed here.

### T

The T-gate is an $R_\phi$ gate with $\phi = \frac{\pi}{4}$:

$$ T = \begin{pmatrix} 1 \ 0 \\ 0 \ e^{i\frac{\pi}{4}} \end{pmatrix} \text{and } T\dagger = \begin{pmatrix} 1 \ 0 \\ 0 \ e^{-i\frac{\pi}{4}} \end{pmatrix}$$

### S

The S-gate is an $R\phi$ gate with $\phi = \frac{\pi}{2}:$

$$ S = \begin{pmatrix} 1 \ 0 \\ 0 \ e^{i\frac{\pi}{2}} \end{pmatrix} \text{and } S\dagger = \begin{pmatrix} 1 \ 0 \\ 0 \ e^{-i\frac{\pi}{2}} \end{pmatrix}$$

It is worth noting that applying two S-gates is, as expected, equivalent to applying a Z-gate.

### $U_3$

All gates shown above can be considered special cases of the $U_3$ gate. It is hardly seen on diagrams, due to the many intricacies that would be involved in using it:

$$ U_3(\theta,\phi,\lambda) = \begin{pmatrix} \cos(\frac\theta2) \ -e^{i\lambda}\sin(\frac\theta2) \\ e^{i\phi}\sin(\frac\theta2) \ e^{i\phi+i\theta}\cos(\frac\theta2) \end{pmatrix}$$

Although Qiskit itself doesn't provide access to the $U_3$ gates, there are two of its special cases that can be accessed via code - the $U_2$ and $U_1$, in which $\theta=\frac\pi2$, and $\theta = \phi = 0$, respectively.

The $U_1$ gate is equivalent to the $R_\phi$ gate.

All single-qubit operations on Qiskit are compiled down to $U_1$, $U_2$, and $U_3$ gates before running on real IBM quantum hardware, and for that reason they are sometimes referred to as _physical gates_.

It should be obvious from this that there are an infinite number of possible gates, and that this also includes Rx and Ry-gates, although they are not mentioned here. It must also be noted that there is nothing special about the Z-basis, except that it has been selected as the standard computational basis. That is why we have names for the S and T-gates, but not their X and Y equivalents.