# Estimating alcohol content in red wines

* Author: Martin Rožnovják
* Last edited: 2019-02-11
* Organization: Metropolia University of Applied Sciences

## What is this?

This notebook is a school assignment for a course called *Cognitive Systems - Mathematics and Methods*.

Its objective is to conduct linear regression analysis on wine properties
from the following UCI dataset https://archive.ics.uci.edu/ml/datasets/Wine+Quality.

Personally, I prefer red wine over white so I'll analyze the red wine dataset.
I'll be interested in estimating the alcohol content of the wines instead
of their quality - just out of interest and objectivity of the measurements,
not because I'm a student... :)

## Let's begin
### All the necessary imports

```python
import IPython

import scipy
import sklearn

import numpy as np
import pandas as pd

import matplotlib.pyplot as plt
import seaborn as sns

from sklearn import metrics, preprocessing
from sklearn.linear_model import LinearRegression
```

Setting options, in particular those for plotting, often interferes with Jupyter's own initializations on import. Therefore, I just do it in the next cell.

```python
### plotting ###
# prettier and larger basic graphs
sns.set(rc={
    'figure.figsize':(18,8),
    'axes.titlesize':14,
})

### pandas ###
# no need to see many decimal places and makes nicer horizontal fits :-)
pd.options.display.float_format = '{:.3f}'.format
# pd.options.display.precision = 3
# make the tables more compact vertically, too
pd.options.display.max_rows = 20

### numpy ###
# same as for pandas - max. 3 decimal places
np.set_printoptions(formatter={'float_kind':'{:.3f}'.format})
# np.set_printoptions(precision=3)
```

### Fetching the dataset

```python
wines_df = pd.read_csv(
    r'https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv',
    sep=';',
)

# a peek whether it went alright
wines_df.head()
```

      fixed acidity
      volatile acidity
      citric acid
      residual sugar
      chlorides
      free sulfur dioxide
      total sulfur dioxide
      density
      pH
      sulphates
      alcohol
      quality

      0
      7.400
      0.700
      0.000
      1.900
      0.076
      11.000
      34.000
      0.998
      3.510
      0.560
      9.400
      5

      1
      7.800
      0.880
      0.000
      2.600
      0.098
      25.000
      67.000
      0.997
      3.200
      0.680
      9.800
      5

      2
      7.800
      0.760
      0.040
      2.300
      0.092
      15.000
      54.000
      0.997
      3.260
      0.650
      9.800
      5

      3
      11.200
      0.280
      0.560
      1.900
      0.075
      17.000
      60.000
      0.998
      3.160
      0.580
      9.800
      6

      4
      7.400
      0.700
      0.000
      1.900
      0.076
      11.000
      34.000
      0.998
      3.510
      0.560
      9.400
      5

No need for the "quality" column, let's forget it...

```python
wines_df.drop(columns=["quality"], inplace=True)
```

### Basic stats and info

```python
print('Summary about the DataFrame:')
wines_df.info();
```

    Summary about the DataFrame:
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 1599 entries, 0 to 1598
    Data columns (total 11 columns):
    fixed acidity           1599 non-null float64
    volatile acidity        1599 non-null float64
    citric acid             1599 non-null float64
    residual sugar          1599 non-null float64
    chlorides               1599 non-null float64
    free sulfur dioxide     1599 non-null float64
    total sulfur dioxide    1599 non-null float64
    density                 1599 non-null float64
    pH                      1599 non-null float64
    sulphates               1599 non-null float64
    alcohol                 1599 non-null float64
    dtypes: float64(11)
    memory usage: 137.5 KB

All looks good so far, nothing missing (just as promised in the dataset description), correct data types...

```python
print('Basic descriptive statistics:')
wines_df.describe()
```

    Basic descriptive statistics:

      fixed acidity
      volatile acidity
      citric acid
      residual sugar
      chlorides
      free sulfur dioxide
      total sulfur dioxide
      density
      pH
      sulphates
      alcohol

      count
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000
      1599.000

      mean
      8.320
      0.528
      0.271
      2.539
      0.087
      15.875
      46.468
      0.997
      3.311
      0.658
      10.423

      std
      1.741
      0.179
      0.195
      1.410
      0.047
      10.460
      32.895
      0.002
      0.154
      0.170
      1.066

      min
      4.600
      0.120
      0.000
      0.900
      0.012
      1.000
      6.000
      0.990
      2.740
      0.330
      8.400

      25%
      7.100
      0.390
      0.090
      1.900
      0.070
      7.000
      22.000
      0.996
      3.210
      0.550
      9.500

      50%
      7.900
      0.520
      0.260
      2.200
      0.079
      14.000
      38.000
      0.997
      3.310
      0.620
      10.200

      75%
      9.200
      0.640
      0.420
      2.600
      0.090
      21.000
      62.000
      0.998
      3.400
      0.730
      11.100

      max
      15.900
      1.580
      1.000
      15.500
      0.611
      72.000
      289.000
      1.004
      4.010
      2.000
      14.900

```python
print('Almost the same thing again, this time graphically:')
wines_df.plot.box(subplots=True, layout=(2, 6), figsize=(20, 10));
```

There seem to be several outliers, I will get back to them soon.

```python
print('Yet different perspective, feature histograms:')
wines_df.hist(bins=20, figsize=(18, 14))
plt.tight_layout()
```

```python
ax = sns.heatmap(
    wines_df.corr(),
    annot=True,
    vmin=-1,
    vmax=1,
    cmap="coolwarm",
    fmt='0.2f',
    linewidths=1,
)
ax.set_title('Correlation Matrix');
```

### Training-testing split

Error estimates are more credible when done on "unseen" data, therefore, I will set aside randomly selected 30% of the data for testing.

I will also make a separate training set with removed outliers for inspecting their role in the regression - how they skew the regression and influence generalization.
I will remove rows that contain a value more than 3 standard deviations in any of the columns.

```python
train_df, test_df = sklearn.model_selection.train_test_split(
    wines_df,
    test_size=0.3,
    shuffle=True,
)

# zcore = (x - x.avg)/x.std
good_zscore = np.abs(scipy.stats.zscore(train_df)) <= 3
trim_index = good_zscore.all(axis=1) # all values in a row have good zscore
train_df_trimmed  = train_df[trim_index]
```

Splitting into inputs (explanatory features) and results (responses).

```python
def xy_split(df):
    x_labels = wines_df.columns[:-1]
    y_label = wines_df.columns[-1]
    return df[x_labels], df[y_label]

x_train,      y_train      = xy_split(train_df)
x_train_trim, y_train_trim = xy_split(train_df_trimmed)
x_test,       y_test       = xy_split(test_df)
```

```python
# a simple utility
def compare_distributions(
    title='Kernel densities (distribution) of response values',
    ax=None,
    kde=True,
    hist=False,
    **label_value_pairs
):
    for label, values in label_value_pairs.items():
        ax = sns.distplot(
            values,
            kde=kde,
            hist=hist,
            label=label,
            ax=ax,
            axlabel=False
        )

    if title: ax.set_title(title)
    # if hist it won't set the legend automatically
    if hist: ax.legend(label_value_pairs.keys())

    return ax
```

```python
# ordinary histogram would be quite confusing...
compare_distributions(
    Training=y_train,
    Trimmed_training=y_train_trim,
    Validation=y_test
);
```

```python
print('Training data:')
train_df.plot.box(subplots=True, figsize=(18,5))
plt.tight_layout()
```

```python
print('Testing data:')
test_df.plot.box(subplots=True, figsize=(18,5))
plt.tight_layout()
```

### Model

The model and regression method are perhaps the simplest possible - i.e. a multilinear model
$\theta_0 + \theta_1 a_1 + \theta_2 a_2 + \dots + \theta_n a_n$
where $a_i$ is the $i$-th attribute (explanatory feature) and $\theta_i, i \in \{0, 1, \dots, n\}$
are the regression coefficients obtained using the least squares method.

If the data has unit variance and zero mean, the absolute value of the resulting coefficients can be
interpreted as the "importance/usefulness" of the corresponding attribute for the problem.
Therefore, I will shift and scale the training data accordingly.

Note: it does not really matter whether the response (results) column is normalized.
If it is not, then the regression coefficients will be multiplied by the standard deviation
of the response column and intercept (the constant term) will be its mean.
I find working with the original values easier in this case (less to worry about).

```python
# some utils

def print_regr_coefs(lin_reg):
    print(f'Intercept: {lin_reg.intercept_:.3f}')
    print('Coefs:', lin_reg.coef_)

def print_metrics(y_true, y_pred, comment=None, show_dists=True):
    mae = metrics.mean_absolute_error(y_true, y_pred)
    mse = metrics.mean_squared_error(y_true, y_pred)
    r2 = metrics.r2_score(y_true, y_pred)

    desc = f'MAE: {mae:.3f}  MSE: {mse:.3f}  R2: {r2:.3f}'
    if comment:
        desc += f'  [{comment}]'

    if show_dists:
        fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 3))
        fig.suptitle(desc)

        residuals = (y_true - y_pred).values
        abs_residuals = np.abs(residuals)

        # distribution of residuals
        sns.distplot(residuals, ax=ax1)
        sns.distplot(abs_residuals, ax=ax1)
        start = np.floor(min(residuals))
        end = np.ceil(max(abs_residuals))
        ax1.xaxis.set_ticks(np.round(np.linspace(start, end, 11),2))
        ax1.legend(['Residuals','Absolute residuals'])

        # dist. of response values
        compare_distributions(
            title=None,
            ax=ax2,
            hist=True,
            Measured=y_true,
            Predicted=y_pred,
        )
        plt.show()
    else:
        print(desc)

def test_report(y_true, y_pred):
    err_df = pd.DataFrame()
    err_df['measured'] = y_true
    err_df['predicted'] = y_pred
    err_df['residual'] = err_df.measured - err_df.predicted
    err_df['abs_residual'] = np.abs(err_df.residual)

    # show statistics
    print('Response statistics:')
    IPython.display.display(err_df.describe())

    ## plot results and residuals
    # true vs. predicted
    ax1 = plt.subplot(1,2,1)
    ax1.set_title('Measured values vs. predicted')
    ax1.plot(y_pred, y_pred)
    err_df.plot.scatter('measured', 'predicted', c='orange', ax=ax1)

    # residuals
    ax2 = plt.subplot(1,2,2)
    ax2.set_title('Residuals')
    # y=0 line
    x_infimum = np.floor(err_df.measured.min())
    x_supremum = np.ceil(err_df.measured.max())
    ax2.plot([x_infimum, x_supremum], [0, 0])
    err_df.plot.scatter('measured', 'residual', c='orange', ax=ax2)

    plt.tight_layout()
    plt.show()
```

```python
print('Regressing on all features, whole training set\n')

# scaling
x_scaler = sklearn.preprocessing.StandardScaler()
x_scaler.fit(x_train)
x_train_scaled = x_scaler.transform(x_train)
x_train_trim_scaled = x_scaler.transform(x_train_trim)
x_test_scaled = x_scaler.transform(x_test)

# regression
reg = LinearRegression().fit(x_train_scaled, y_train)

# reports
print_regr_coefs(reg)

y_pred_train = reg.predict(x_train_scaled)
print_metrics(y_train, y_pred_train, 'training')

y_pred_test = reg.predict(x_test_scaled)
print_metrics(y_test, y_pred_test, 'validation')
print()
test_report(y_test, y_pred_test)
```

The mean average error (same as mean absolute residual) tells us
that the model is on average off by 0.48% off from the actual percentual content of the alcohol,
considering that the range of the alcohol volume is 8.4% - 14.9%,
this can be translated to about 7.4% relative error.
From the "Response statistics" table, based on the quantiles of absolute residual,
we can see that a quarter of the results are predicted within 0.2% (absolute error),
half of them within 0.4%, three quarters only within 0.7%
and the worst error is 2.4% off.

```python
print('Features by "importance" for the last regression:')
coef_df = pd.DataFrame()
coef_df['feature'] = x_train.columns
coef_df['coef'] = reg.coef_
coef_df['abs_coef'] = abs(coef_df['coef'])
coef_df['natural_coef'] = x_scaler.inverse_transform(reg.coef_)
coef_df.sort_values('abs_coef', ascending=False, inplace=True)
coef_df.reset_index(drop=True, inplace=True)
coef_df
```

    Features by "importance" for the last regression:

      feature
      coef
      abs_coef
      natural_coef

      0
      density
      -1.118
      1.118
      0.995

      1
      fixed acidity
      0.904
      0.904
      9.912

      2
      pH
      0.571
      0.571
      3.395

      3
      residual sugar
      0.397
      0.397
      3.093

      4
      sulphates
      0.200
      0.200
      0.690

      5
      citric acid
      0.170
      0.170
      0.305

      6
      chlorides
      -0.086
      0.086
      0.083

      7
      total sulfur dioxide
      -0.076
      0.076
      43.908

      8
      volatile acidity
      0.076
      0.076
      0.543

      9
      free sulfur dioxide
      -0.025
      0.025
      15.461

```python
# I will leave this here, perhaps I had too long weekend...
print('Formula for estimating alcohol content in red wine:')
print('(features by contribution, no need to scale)')
IPython.display.Math(
    r'\begin{align}' +
    r'{:.3f}\ &+\ '.format(reg.intercept_) +
    ''.join([
        r'\\& {:+.3f} \cdot [\verb|{}|]'.format(coef, ftr)
        for coef, ftr in coef_df[['natural_coef', 'feature']].values
    ]) +
    r'\end{align}'
)
```

    Formula for estimating alcohol content in red wine:
    (features by contribution, no need to scale)

$\displaystyle \begin{align}10.396\ &+\ \\& +0.995 \cdot [\verb|density|]\\& +9.912 \cdot [\verb|fixed acidity|]\\& +3.395 \cdot [\verb|pH|]\\& +3.093 \cdot [\verb|residual sugar|]\\& +0.690 \cdot [\verb|sulphates|]\\& +0.305 \cdot [\verb|citric acid|]\\& +0.083 \cdot [\verb|chlorides|]\\& +43.908 \cdot [\verb|total sulfur dioxide|]\\& +0.543 \cdot [\verb|volatile acidity|]\\& +15.461 \cdot [\verb|free sulfur dioxide|]\end{align}$

## Conclusion

I experimented with leaving some columns and with the threshold for trimming outliers,
the results were, however, insignificant and inconsistent, so I won't clutter the notebook
with examples of those, I will just leave one quite general cell for quickly playing
with  these adjustments (just comment out columns, change training set, etc.).

This approximation method is trying to minimize the MSE metric.
In my opinion, it would be better to minimize error so that it favors
the majority of the data and not being influenced much by edge cases.

```python
### tinkering cell ###

# scaling
columns = [
    'density',
    'fixed acidity',
    'pH',
    'residual sugar',
    'sulphates',
    'citric acid',
    'volatile acidity',
    'chlorides',
    'total sulfur dioxide',
    'free sulfur dioxide'
]
x_scaler = sklearn.preprocessing.StandardScaler()
x_scaler.fit(x_train_trim[columns])
x_train_scaled = x_scaler.transform(x_train[columns])
x_train_trim_scaled = x_scaler.transform(x_train_trim[columns])
x_test_scaled = x_scaler.transform(x_test[columns])

# regression
reg = LinearRegression().fit(x_train_trim_scaled, y_train_trim)
# reg.intercept_ = 10.2

# reports
print_regr_coefs(reg)

y_pred_train_trim = reg.predict(x_train_trim_scaled)
print_metrics(y_train_trim, y_pred_train_trim, 'training trimmed')

y_pred_train = reg.predict(x_train_scaled)
print_metrics(y_train, y_pred_train, 'whole training data')

y_pred_test = reg.predict(x_test_scaled)
print_metrics(y_test, y_pred_test, 'validation')
print()
test_report(y_test, y_pred_test)
```


```python
from sympy import Symbol, nsolve, solve
import sympy
import mpmath
import numpy as np
import matplotlib.pyplot as plt
import math as m
mpmath.mp.dps = 15
w = Symbol('w')
x = Symbol('x')
y = Symbol('y')
z = Symbol('z')
H = Symbol('H')
OH = Symbol('OH')
```

```python
Kw = 10.000**(-14)
#Ka1 = 0.00071 #citric acid
#Ka2 = 0.000017
#Ka3 = 0.0000064
Ka1 = .0069 # phosphoric acid
Ka2 = .000000062
Ka3 = .00000000000048

C = .05 #concentration of acid (M)
```

```python
Hguess = (Ka1*C)**(1/2) # a rough initial guess for the concentration of hydrognium in solution
OHguess = Kw/Hguess
f1 = H*OH - Kw
f2 = x + 2.0*y + 3.0*z + OH - H
f3 = H*x - Ka1*w # w is the concentration of acid with all 3 hydrogen atoms still attached, x is the concentration of acid with one lost hydrogen
f4 = H*y - Ka2*x #y is the concentration of acid with 2 less hydrogen atoms
f5 = H*z - Ka3*y #z is the concentration of acid without all 3 hydrogen atoms
f6 = w + x + y + z - C
answers = nsolve((f1, f2, f3, f4, f5, f6), (w, x, y, z, H, OH), (1,1,1,1,Hguess,OHguess)) #solve for concentration of w, x, y, z and the concentrations of hydronium and hydroxide
answers
```

$\displaystyle \left[\begin{matrix}0.0345581366722643\\0.0154418013282335\\6.19995021360192 \cdot 10^{-8}\\1.92720534476029 \cdot 10^{-18}\\0.0154419253278854\\6.4758764128601 \cdot 10^{-13}\end{matrix}\right]$

```python
print("the pH of ", C, "M of the acid is ", (-m.log10(answers[4])))
```

    the pH of  0.05 M of the acid is  1.8112985519829656

```python
Hguess = (Ka1*C)**(1/2) # a rough initial guess for the concentration of hydrognium in solution
OHguess = Kw/Hguess
NaOH = C #concentration of NaOH
Hlist = []
pHlist = []

vol1 = 0
vol2 = 3.5 #total volume of NaOH used in titration

NaOHsteps = np.arange(vol1*NaOH,vol2*NaOH,(vol2-vol1)*NaOH/200) #list of the amounts of NaOH used in each step
vollist = np.arange(vol1,vol2,(vol2-vol1)/200) #list of additional volume after the 1L of acid
for i in range(len(NaOHsteps)): #use the same pH calculation method, but modified to add volume, and add NaOH to the equations
    newC = C/(1+vollist[i])
    f1 = H*OH - Kw
    f2 = x + 2.0*y + 3.0*z + OH - H - NaOHsteps[i]/(1+vollist[i])
    f3 = H*x - Ka1*w
    f4 = H*y - Ka2*x
    f5 = H*z - Ka3*y
    f6 = w + x + y + z - newC
    answers = nsolve((f1, f2, f3, f4, f5, f6), (w, x, y, z, H, OH), (1,1,1,1,Hguess,i))
    Hguess = answers[4]

    Hlist.append(answers[4]) #save the concentration of hydronium
    pH = -1*m.log10(answers[4]) #calculate pH and add to a list
    pHlist.append(pH)

print(len(NaOHsteps),len(vollist))
```

    200 200

```python
plt.figure(figsize=(21,12))
plt.xlabel("NaOH added")
plt.ylabel("pH of solution")
#plt.title("Titration of citric acid")
plt.plot(vollist,pHlist)
plt.yticks(np.arange(0, 12, 1))
plt.show()
```

```python

```


# Design of a Cold Weather Fuel for a Camping Stove

The venerable alcohol stove has been invaluable camping accessory for generations. They are simple, reliable, and in a pinch, can be made from aluminum soda cans.

Alcohol stoves are typically fueled with denatured alcohol. Denatured alcohol, sometimes called methylated spirits, is a generally a mixture of ethanol and other alcohols and compounds designed to make it unfit for human consumption. An MSDS description of one [manufacturer's product](https://www.korellis.com/wordpress/wp-content/uploads/2016/05/Alcohol-Denatured.pdf) describes a roughly fifity/fifty mixture of ethanol and methanol.

The problem with alcohol stoves is they can be difficult to light in below freezing weather. The purpose of this notebook is to design of an alternative cold weather fuel that could be mixed from other materials commonly available from hardware or home improvement stores.

## Data

The following data was collected for potential fuels commonly available at hardware and home improvement stores. The data consists of price (\$/gal.) and parameters to predict vapor pressure using the Antoine equation,

\begin{align}
\log_{10}P^{vap}_{s}(T) & = A_s - \frac{B_s}{T + C_s}
\end{align}

where the subscript $s$ refers to species, temperature $T$ is in units of degrees Celcius, and pressure $P$ is in units of mmHg. The additional information for molecular weight and specific gravity will be needed to present the final results in volume fraction.

```python
data = {
    'ethanol'          : {'MW':  46.07, 'SG': 0.791, 'A': 8.04494, 'B': 1554.3,  'C': 222.65},
    'methanol'         : {'MW':  32.04, 'SG': 0.791, 'A': 7.89750, 'B': 1474.08, 'C': 229.13},
    'isopropyl alcohol': {'MW':  60.10, 'SG': 0.785, 'A': 8.11778, 'B': 1580.92, 'C': 219.61},
    'acetone'          : {'MW':  58.08, 'SG': 0.787, 'A': 7.02447, 'B': 1161.0,  'C': 224.0},
    'xylene'           : {'MW': 106.16, 'SG': 0.870, 'A': 6.99052, 'B': 1453.43, 'C': 215.31},
    'toluene'          : {'MW':  92.14, 'SG': 0.865, 'A': 6.95464, 'B': 1344.8,  'C': 219.48},
}
```

## Denatured Alcohol

The first step is to determine the vapor pressure of denatured alcohol over a typical range of operating temperatures. For this we assume denatured alcohol is a 40/60 (mole fraction) mixture of ethanol and methanol.

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

def Pvap(T, s):
    return 10**(data[s]['A'] - data[s]['B']/(T + data[s]['C']))

def Pvap_denatured(T):
    return 0.4*Pvap(T, 'ethanol') + 0.6*Pvap(T, 'methanol')

T = np.linspace(0, 40, 200)

plt.plot(T, Pvap_denatured(T))
plt.title('Vapor Pressure of denatured alcohol')
plt.xlabel('temperature / °C')
plt.ylabel('pressure / mmHg')
plt.grid()
print("Vapor Pressure at 0C =", round(Pvap_denatured(0),1), "mmHg")
```

## Cold Weather Product Requirements

We seek a cold weather fuel with increased vapor pressure at 0°C and lower, and also provides safe and normal operation of the alcohol stove at higher operating temperatures.

For this purpose, we seek a mixture of commonly available liquids with a vapor pressure of at least 22 mmHg at the lowest possible temperature, and no greater than the vapor pressure of denatured alcohol at temperatures 30°C and above.

```python
for s in data.keys():
    plt.plot(T, Pvap(T,s))
plt.plot(T, Pvap_denatured(T), 'k', lw=3)
plt.legend(list(data.keys()) + ['denatured alcohol'])
plt.title('Vapor Pressure of selected compounds')
plt.xlabel('temperature / °C')
plt.ylabel('pressure / mmHg')
plt.grid()
```

## Optimization Model

The first optimization model is to create a mixture that maximizes the vapor pressure at -10°C while having a vapor pressure less than or equal to denatured alcohol at 30°C and above.

The decision variables in the optimization model correspond to $x_s$, the mole fraction of each species $s \in S$ from the set of available species $S$. By definition, the mole fractions must satisfy

\begin{align}
x_s & \geq 0 & \forall s\in S \\
\sum_{s\in S} x_s & = 1
\end{align}

The objective is to maximize the vapor pressure at low temperatures, say -10°C, while maintaing a vapor pressure less than or equal to denatured alcohol at 30°C. Using Raoult's law for ideal mixtures,

\begin{align}
\max_{x_s} \sum_{s\in S} x_s P^{vap}_s(-10°C) \\
\end{align}
subject to
\begin{align}
\sum_{s\in S} x_s P^{vap}_s(30°C) & \leq P^{vap}_{denatured\ alcohol}(30°C) \\
\end{align}

This optimization model is implemented in Pyomo in the following cell.

```python
import pyomo.environ as pyomo

m = pyomo.ConcreteModel()

S = data.keys()
m.x = pyomo.Var(S, domain=pyomo.NonNegativeReals)

def Pmix(T):
    return sum(m.x[s]*Pvap(T,s) for s in S)

m.obj = pyomo.Objective(expr = Pmix(-10), sense=pyomo.maximize)

m.cons = pyomo.ConstraintList()

m.cons.add(sum(m.x[s] for s in S)==1)
m.cons.add(Pmix(30) <= Pvap_denatured(30))
m.cons.add(Pmix(40) <= Pvap_denatured(40))

solver = pyomo.SolverFactory('glpk')
solver.solve(m)

print("Vapor Pressure at -10°C =", m.obj(), "mmHg")

T = np.linspace(-10,40,200)
plt.plot(T, Pvap_denatured(T), 'k', lw=3)
plt.plot(T, [Pmix(T)() for T in T], 'r', lw=3)
plt.legend(['denatured alcohol'] + ['cold weather blend'])
plt.title('Vapor Pressure of selected compounds')
plt.xlabel('temperature / °C')
plt.ylabel('pressure / mmHg')
plt.grid()
```

## Display Composition

```python
import pandas as pd

s = data.keys()
results = pd.DataFrame.from_dict(data).T
for s in S:
    results.loc[s,'mole fraction'] = m.x[s]()

MW = sum(m.x[s]()*data[s]['MW'] for s in S)
for s in S:
    results.loc[s,'mass fraction'] = m.x[s]()*data[s]['MW']/MW

vol = sum(m.x[s]()*data[s]['MW']/data[s]['SG'] for s in S)
for s in S:
    results.loc[s,'vol fraction'] = m.x[s]()*data[s]['MW']/data[s]['SG']/vol

results
```

      A
      B
      C
      MW
      SG
      mole fraction
      mass fraction
      vol fraction

      acetone
      7.02447
      1161.00
      224.00
      58.08
      0.787
      0.428164
      0.2906
      0.311695

      ethanol
      8.04494
      1554.30
      222.65
      46.07
      0.791
      0.000000
      0.0000
      0.000000

      isopropyl alcohol
      8.11778
      1580.92
      219.61
      60.10
      0.785
      0.000000
      0.0000
      0.000000

      methanol
      7.89750
      1474.08
      229.13
      32.04
      0.791
      0.000000
      0.0000
      0.000000

      toluene
      6.95464
      1344.80
      219.48
      92.14
      0.865
      0.000000
      0.0000
      0.000000

      xylene
      6.99052
      1453.43
      215.31
      106.16
      0.870
      0.571836
      0.7094
      0.688305

```python

```


# IFT2505 Séance de démonstration 2

## 1. Rappels

Soit le problème linéaire suivant:
\begin{align*}
  \min\      & c^T x \\
  \text{sujet à } & Ax =b \\
                  &  x\geq 0
\end{align*}

$A$ une matrice de $m$ lignes et $n$ colonnes $(m \times n)$ et $b$ un vecteur colonne de $m$ lignes.
On suppose $m\leq n$ et $A$ est supposée de rang plein (i.e. les lignes de $A$ sont linéairement indépendantes, $rang(A) = m$).

**Définition 1**
Une sous-matrice $B$ de $A$ est dite $\textbf{base}$ si B est une sous-matrice carrée inversible (i.e. $B^{-1} existe$), de dimensions $m \times m$ (i.e. $B$ est constituée de $m$ colonnes de $A$).

**Définition 2**
Les *variables de base* $x_B$ sont les variables associées aux colonnes de $A$ constituant la base $B$.

**Définition 3**
Une solution de base associée à la base $B$, notée $w$ correspond à poser les variables hors bases à zéro (elles sont au nombre de $n-m$), et à déterminer le vecteur des variables de base $x_B = B^{-1}b$. Sans perte de généralité, supposons que $B$ est constitué des $m$ premières colonnes de $A$ et définissons $x_R$ le vecteur regroupant les variables hors base; on représente $w= \begin{pmatrix} x_B & x_R \end{pmatrix}^T$.

$\textbf{Définition 4}$
Une solution de base est dite réalisable si $x_B \geq 0$.

$\textbf{Définition 5}$
Une solution de base est dite non dégénérée si $x_B > 0$.

## 2. Luenberger, Exercice 5 page 29

Transformez le problème suivant sous forme standard, et trouvez la solution de base optimale:
\begin{align*}
  \max\            & x_1+4x_2 +x_3 \\
  \text{sujet à } &   2x_1 -2x_2 +x_3 = 4 \\
                  &    x_1 - x_3 =1 \\
                  &    x_2, x_3 \geq 0
\end{align*}

$x_1$ est une variable libre.

Nous utilisons la contrainte $ x_1 - x_3 =1 $ pour simplifier le problème: $x_1 = 1 + x_3$.

Alors, $2x_1 -2x_2 +x_3 = 3$ devient $ -2x_2 + 3x_3 = 2$.

L'objectif devient :
$$
4x_2 +2x_3 +1
$$

On s'intéresse seulement à la solution optimale et non à la valeur de l'objectif.
Ainsi, le problème sous forme standard est alors équivalent à:
\begin{align*}
  -\min             &   -4x_2 -2x_3 \\
  \text{sujet à }  &   -2x_2 + 3x_3 = 2 \\
                   &    x_2, x_3 \geq 0
\end{align*}

Le matrice des contraintes linéaires est
$$
A = \begin{pmatrix}
      -2 & 3
\end{pmatrix}
$$
$A$ ne compte que deux sous-matrices (qui sont des scalaires):
$A2 = (-2)$ et $A3 = (3)$.
$A2$ et $A3$ sont inversibles, donc $A2$ et $A3$ sont des bases.
Les solution correspondant à chaque base est:

$A2$: $x_2 = -1$ et $x_3 = 0$

où $x_2$ est variable de base.

$A3$: $x_3 = \frac{2}{3}$ et $x_2 = 0$

où $x_3$ est variable de base.

Seule A3 est une base réalisable puisque la solution correspondante respecte la non négativité des variables.

```julia
using Plots
```

```julia
plot(x -> (2 + 2x)/3, color = :blue, linewidth = 2, label = "Contrainte")
hline!([0], color = :black, linestyle = :dash, label = "")
vline!([0], color = :black, linestyle = :dash, label = "")
plot!(xlims = (-2, 3), ylims = (-0.5, 4))
scatter!([-1], [0], markersize = 5, label = "Solution associé à la base A2", color = :red)
scatter!([0], [2/3], markersize = 5, label = "Solution associé à la base A3", color = :green)
plot!(title = "well Chosen Title")
plot!(xlabel = "x2", ylabel = "x3")
```

$\textbf{Remarque importante : }$
Le problème n'est pas borné inférieurement. Il n'y a donc pas de solution optimale. A3 n'est pas une base optimale.

## 3. Exercice 3

Considérons le problème suivant
\begin{align*}
  \min_x\ & c^Tx \\
  \mbox{sujet à } & Ax = b \\
\end{align*}
qui s'écrit aussi:
\begin{align*}
  \text{Min }     & c^T x \\
  \text{Sujet à } & \sum_{j = 1}^{n} a_{ij}x_j = b_i,\ i= 1, ..., m \\
\end{align*}
En ramenant le problème à la forme standard, montrez qu'il n'existe pas de solution de base telle que pour un indice $j$ donné, $x_j^{+}$ et $x_j^{-}$ soient dans la base.

Les variables $x_j$ étant libres, on les remplace par:
$x_j = x_j^{+} - x_j^{-}$, avec $x_j^{+}, x_j^{-}\geq 0$.

On fait les substitions et on obtient le problème sous *forme standard*
\begin{align*}
\min_x\ & c^t x^+ - c^t x^-\\
\text{ Sujet à } & \sum_{j=1}^n a_{ij} x_j^+ - \sum_{j=1}^n a_{ij} x_j^- = b_i,\ i=1,\ldots, m\\
 & x^+, \ x^- \ge 0
\end{align*}

Montrons qu'il n'existe pas de solution de base, où pour un indice $j$ donné, $x_j^{+}$ et $x_j^{-}$ sont dans la base.

$\textit{La preuve est une preuve par contradiction.}$
Supposons qu'il existe une base $B$, qui pour un $j$ donné, comprend les colonnes associées aux variables $x_j^{+}$ et $x_j^{-}$.
$B$ est donc de la forme:
$$
B = \begin{pmatrix}
     \cdots & a_{1j} & \cdots & -a_{1j} & \cdots \\
     \cdots & a_{2j} & \cdots & -a_{2j} & \cdots \\
     \cdots & a_{3j} & \cdots & -a_{3j} & \cdots \\
         &  \vdots  & \ddots &  \vdots \\
     \cdots & a_{mj}  & \cdots & -a_{mj} & \cdots
\end{pmatrix}
$$
$B$ a alors 2 colonnes linéairement dépendantes, ce qui est en contradiction avec l'inversibilité de $B$, et donc que $B$ est une base.

## 4. Exercice 8 page 30

Transformez le problème suivant en un programme linéaire sous forme standard:
\begin{align*}
\min\ & |x| + |y| + |z| \\
\mbox{t.q. } &  x + y \leq 1 \\
& 2x + z = 3
\end{align*}

Introduisons 3 nouvelles variables positives ou nulles.

$u = |x|$, $v = |y|$, $w = |z|$

$|x| = \max \{x, -x\} $

Par conséquent:

\begin{align*}
u \geq x  \quad u \geq -x
\end{align*}

De même :
\begin{align*}
v \geq  y  \quad v \geq -y \\
w \geq  z  \quad w \geq -z
\end{align*}

x, y et z étant des variables libres nous les exprimons par une différence de deux variables positives:
\begin{eqnarray}
% \nonumber to remove numbering (before each equation)
  x &=& x^+ - x^- \\
  y &=& y^+ - y^- \\
  z &=& z^+ - z^-
\end{eqnarray}
$x^+, x^-, y^+, y^-, z^+, z^- \geq 0$

On remplace x, y et z par leurs expressions.
La forme standard du problème est:

\begin{align*}
  \min\ & u + v + w  \\
  \text{Sujet à }& -u + x^+ - x^-  + s_1  = 0\\
   & -u - x^+ + x^- + s_2  = 0\\
   & -v + y^+ - y^- + s_3  = 0\\
   & -v - y^+ + y^- + s_4  = 0\\
   & -w + z^+ - z^- + s_5  = 0\\
   & -w - z^+ + z^- + s_6  =  0\\
   & x^+ -x^- + y^+ -y^- + s = 1 \\
   & 2x^+ - 2x^- + z^+ - z^- = 3 \\
   & u, v, w \geq 0 \\
   & x^+, x^-, y^+, y^-, z^+, z^- \geq 0 \\
   & s, s_1, s_2, s_3, s_4, s_5, s_6 \geq 0
\end{align*}

## 5. Julia

**Ecrivez un code Julia permettant de calculer toutes les solutions de base d'un système linéaire $Ax = b$.**

**Écrivez une fonction qui trouve le minimum d'un programme linéaire (en assumant qu'il existe) en utilisant la fonction précédente.**

Nous allons essentiellement remettre en fonction ce que nous avions fait à la démonstration $1$.

Nous allons introduire une fonction permettant de calculer et stocker les solutions de base et les bases associées.

Le concept de fonction en Julia est expliqué à la page https://docs.julialang.org/en/v1/manual/functions/index.html

Une fonction est un objet qui prend en entrée une liste d'arguments et retourne une valeur.

```julia
using Combinatorics
using LinearAlgebra
```

```julia
function basic_sols(A, b)
    m, n = size(A)
    C = collect(combinations(1:n,m)) # every combination of n choose m
    xb = Vector{Float64}()
    basis = Vector{Int64}()
    for i = 1:length(C)
        B = A[:,C[i]]
        if rank(B) == m
            # A vector is by defaut column-oriented, so we use vcat
            xb = vcat(xb, B\b)
            basis = vcat(basis, C[i])
        end
    end
    return xb, basis
end
```

    basic_sols (generic function with 1 method)

Limitons-nous aux bases réalisables

```julia
function feasible_basic_sols(A, b)
    bases = []
    sols = []

    m, n = size(A)
    comb = collect(combinations(1:n,m)) # every combination of n choose m
    for i = 1:length(comb)
        B = A[:,comb[i]]
        if rank(B) == m
            y = B\b # analytically equivalent to y = B^{-1}*b
            if all( y .>= 0)
                # A vector is by defaut column-oriented, so we use vcat
                bases = vcat(bases, i)
                sol = zeros(n)
                sol[comb[i]] = y
                sols = vcat(sols, [sol])
            end
        end
    end
    return sols, bases
end
```

    feasible_basic_sols (generic function with 1 method)

Finalement, nous pouvons chercher la solution optimale en cherchant celle qui donne la plus petite valeur de la fonction objectif.

```julia
function enumerate_solve(A,b,c)
    feasible_basic_sols(A, b)
    sols, B = feasible_basic_sols(A, b);
    idx = 1
    opt = c'*sols[1][1:2]
    for i = 2:length(sols)
        temp = c'*sols[i][1:2]
        if (temp < opt)
            idx = i
            opt = temp
        end
    end
    return sols[idx], opt
end
```

    enumerate_solve (generic function with 1 method)

#### Exemple 3 page 25
\begin{align}
    \text{Min} & -2x_1 - x_2 \\
    \text{Sujet à } & x_1 + \frac{8}{3}x_2 + x_3 = 4 \\
            & x_1+x_2 + x_4 = 2 \\
            & 2x_1 + x_5 = 3 \\
            & x_1, x_2, x_3, x_4, x_5 \geq 0
\end{align}

```julia
A = [1 8/3 1 0 0; 1 1 0 1 0; 2 0 0 0 1]
b = [4.0, 2, 3]
c = [-2.0, -1]
xstar, vstar = enumerate_solve(A,b,c)
```

    ([1.5, 0.5, 1.1666666666666667, 0.0, 0.0], -3.5)

```julia
#vérification des résultats, xstar est vraiment une solution réalisable??
println(A*xstar - b)
#oui
```

    [0.0, 0.0, 0.0]

## Résolutions de systèmes linéaires et inversion de matrices

Remarquons que nous n'avons pas utiliser l'inversion de matrice pour résoudre le système $Bx_B = b$, alors que mathématiquement, la réponse est $x_B = B^{-1}b$. Pourtant, Julia dispose d'une fonction pour inverser une matrice: `inv`. Numériquement, il est conseillé d'éviter autant que possible de calculer l'inverse d'une matrice.

Nous allons considérer pour illustrer le phénomène un exemple adapté de https://www.reddit.com/r/programming/comments/argsg/dont_invert_that_matrix/c0j17m7/

La librairie MatrixDepot permet de générer diverses matrices ayant une structure particulière. Nous allons ici considérer une matrice de Hilbert de taille 10 par 10.

```julia
#using MatrixDepot
```

```julia
n = 10
#A = matrixdepot("hilb", n)
A = [1/(i + j - 1) for i in 1:n, j in 1:n]
```

    10×10 Array{Float64,2}:
     1.0       0.5        0.333333   …  0.125      0.111111   0.1
     0.5       0.333333   0.25          0.111111   0.1        0.0909091
     0.333333  0.25       0.2           0.1        0.0909091  0.0833333
     0.25      0.2        0.166667      0.0909091  0.0833333  0.0769231
     0.2       0.166667   0.142857      0.0833333  0.0769231  0.0714286
     0.166667  0.142857   0.125      …  0.0769231  0.0714286  0.0666667
     0.142857  0.125      0.111111      0.0714286  0.0666667  0.0625
     0.125     0.111111   0.1           0.0666667  0.0625     0.0588235
     0.111111  0.1        0.0909091     0.0625     0.0588235  0.0555556
     0.1       0.0909091  0.0833333     0.0588235  0.0555556  0.0526316

La matrice de Hilbert est définie en prenant $\forall i, j$:
$$
a_{ij} = \frac{1}{i + j - 1}.
$$
Ceci peut se voir en affichant les entrées de la matrice sous forme de rationels:

```julia
#matrixdepot("hilb", Rational, n)
[1//(i + j - 1) for i in 1:n, j in 1:n]
```

    10×10 Array{Rational{Int64},2}:
     1//1   1//2   1//3   1//4   1//5   1//6   1//7   1//8   1//9   1//10
     1//2   1//3   1//4   1//5   1//6   1//7   1//8   1//9   1//10  1//11
     1//3   1//4   1//5   1//6   1//7   1//8   1//9   1//10  1//11  1//12
     1//4   1//5   1//6   1//7   1//8   1//9   1//10  1//11  1//12  1//13
     1//5   1//6   1//7   1//8   1//9   1//10  1//11  1//12  1//13  1//14
     1//6   1//7   1//8   1//9   1//10  1//11  1//12  1//13  1//14  1//15
     1//7   1//8   1//9   1//10  1//11  1//12  1//13  1//14  1//15  1//16
     1//8   1//9   1//10  1//11  1//12  1//13  1//14  1//15  1//16  1//17
     1//9   1//10  1//11  1//12  1//13  1//14  1//15  1//16  1//17  1//18
     1//10  1//11  1//12  1//13  1//14  1//15  1//16  1//17  1//18  1//19

Définissons un vecteur avec des 1 comme seules entrées, et formons le terme de droite $b$ en le multipliant par $A$.

```julia
x = ones(n)
b = A*x
```

    10-element Array{Float64,1}:
     2.9289682539682538
     2.0198773448773446
     1.603210678210678
     1.3468004218004217
     1.168228993228993
     1.0348956598956598
     0.9307289932289932
     0.8466953797836151
     0.7772509353391707
     0.718771403175428

Analysons l'erreur numérique en résolvant le système $Ax = b$.

```julia
[norm(x-A\b), norm(x-inv(A)*b)]
```

    2-element Array{Float64,1}:
     0.0002741818135171358
     0.0007910416733272217

Nous voyons que l'erreur numérique est plus grande est résolvant le système à l'aide de l'inversion de matrice qu'en appelant la commande `A\b`.

Analysons aussi son temps d'exécution. Pour ce faire, nous utiliserons la libraire BenchmarkTools.

```julia
using BenchmarkTools
```

```julia
@benchmark A\b
```

    BenchmarkTools.Trial:
      memory estimate:  1.19 KiB
      allocs estimate:  3
      --------------
      minimum time:     1.059 μs (0.00% GC)
      median time:      1.095 μs (0.00% GC)
      mean time:        1.247 μs (2.19% GC)
      maximum time:     145.586 μs (98.20% GC)
      --------------
      samples:          10000
      evals/sample:     10

```julia
@benchmark (inv(A)*b)
```

    BenchmarkTools.Trial:
      memory estimate:  6.34 KiB
      allocs estimate:  5
      --------------
      minimum time:     1.923 μs (0.00% GC)
      median time:      2.014 μs (0.00% GC)
      mean time:        2.357 μs (10.37% GC)
      maximum time:     191.855 μs (96.48% GC)
      --------------
      samples:          10000
      evals/sample:     10

En plus d'être moins précise, la méthode est plus lente!


# Quantum Walk Search Algorithm
Quantum walks are the quantum equivalence of a classical Markov chain and have become key in many quantum algorithms. In this section, we implement a quantum walk search algorithm that finds marked elements in a graph. The algorithm has a quadratic speedup compared to its classical counterpart.
## Contents
1. [Classical Markov chains](#markov)
2. [Quantum walks](#qwalks)
    1. [Coined quantum walkes](#coined)
    2. [Szedgedy quantum walk](#szegedy)
    3. [Equivalence of coined and Szegedy quantum walks](#equivalence)
3. [Example: Quantum walk on hypercube](#example1)
4. [Quantum walk search algorithm](#qwalkalgo)
5. [Example: quantum walk search on 4-dimensional hypercube](#qiskiti)
6. [References](#references)

## 1.Classical Markov chains
A Markov chain is a stochastic process often used to model real-life processes. It consists of states and associated transition probabilities, which describe the probabilities to move between the states in each time step. In discrete-time Markov chains, which we work with here, the time steps are discrete. Markov chains satisfy the Markov property, which means that the next step of the process only depends on the current and not any of the previous steps. A Markov chain has an associated transition matrix, P, which describes the probability to move between each of the states. We show an example of a Markov chain and its associated transition matrix $P$ below.
\begin{equation}
P=
\begin{pmatrix}
0.1 & 0.3 & 0.3\\
0.1 & 0.1 & 0.2 \\
0.8 & 0.6 & 0.5
\end{pmatrix}
\label{eq:matrix_example}
\end{equation}

Given a transition matrix $P$, we can obtain the probability distribution after $t$ time steps by $P^t$.

## 2.Quantum walks
Quantum walks are the quantum equivalent of the classical Markov chain. Due to superposition, a quantum walk will take all possible paths simultaneously until we measure the circuit. Due to quantum interference, some state will cancel out. This makes quantum walk algorithms faster than classical ones, since we can design them in such way that wrong answers quickly cancells out. There are two common models for quantum walks, coined quantum walks and Szegedy quantum walks, which are equivalent under certain circumstances. A coined quantum walk is a walk on the vertices of the graph while Szegedy quantum walk takes place on the edges. Before we show how we can implement a quantum walk, we will introduce both models.

 $\newcommand{\ket}[1]{\left|{#1}\right\rangle}$
 $\newcommand{\bra}[1]{\left\langle{#1}\right|}$
### 2.A Coined quantum walks
A simple example of a coined quantum walk is a walk on the infinite integer line. In this case, we represent the walker's position with an integer, $\{\ket{j} : j \in  \mathbb{Z} \}$, since the walker can walk all integers in $\mathbb{Z}$. A coin decides how the walker should move. On the integer line, the walker can go either left or right. Therefore, the coin's computational basis is $\{\ket{0}, \ket{1}\}$, and we move the walker in one direction if the coin is $\ket{0}$ and in the other direction if the coin is $\ket{1}$.

A coined quantum is a walk on the nodes in a graph, and we refer to the nodes as states. The walker can move between states that are connected with an edge. In the coin model, we have two quantum states and two operators. The first state is the position state, which represents the walker's position. For the walk above, this is an integer since the walker can be anywhere on the integer line. The other state is the coin state. The coin state decides how the walker should move in the next step. We can represent both the coin state and the position state by vectors in Hilbert space. If we can express the coin state by a vector in $\mathcal{H}_C$ and the position state with a vector in $\mathcal{H}_P$, we can express the quantum space for the entire walker as $\mathcal{H} = \mathcal{H}_C \otimes \mathcal{H}_P$.

As we mentioned, the model also has two operators; the coin operator, $C$, and the shift operator, $S$. The coin operator acts on $\mathcal{H}_C$ during each time step and puts the walker in superposition, so it walks all possible paths simultaneously. For the walk on the integer line, this means that it moves both to the left and right in each time step. There are different coin operators, but the most common ones are the Hadamard coin and the Grover coin. A Hadamard coin is a Hadamard gate that puts the walker in an equal superposition:

\begin{equation}
    H = \frac{1}{\sqrt{2}}
\begin{bmatrix}
1 & 1 \\
1 & -1
\end{bmatrix}
\end{equation}

A Grover coin is the Grover diffusion operator from Grover's algorithm. We define it as

\begin{equation}
    G =
    \begin{bmatrix}
\frac{2}{n} -1 & \frac{2}{n} & \ldots & \frac{2}{n}\\
\frac{2}{n} & \frac{2}{n} - 1  & \ldots & \frac{2}{n} \\
\vdots & \vdots & \ddots & \vdots \\
\frac{2}{n} & \frac{2}{n} & \ldots & \frac{2}{n} -1
\end{bmatrix}
\end{equation}

Like the Hadamard coin, a Grover coin puts the walker in superposition. However, it behaves a bit differently. If we apply a Grover coin to a walker in position $\ket{000}$, we obtain the state vector probabilities shown in the figure below. As we can see, the coin does not put the walker in an equal superposition as the Hadamard coin does. Instead, $\ket{000}$ has a much larger probability than the other states.

The other operator in the model, the shift operator, acts on $\mathcal{H}_P$ and moves the walker to the next position. For the walk on the integer line, the shift operator moves the walker to the left of the coin is $\ket{0}$ and to the right if the coin is $\ket{1}$:

\begin{equation}
    S \ket{0}\ket{j} = \ket{0}\ket{j+1}
\end{equation}

\begin{equation}
    S \ket{1}\ket{j} = \ket{1}\ket{j-1}
\end{equation}

With the shift operator defined as above, we can represent one step of the coined quantum as the unitary operator $U$ given by
\begin{equation}
    U = SC,
\end{equation}

where C is the coin operator. For the quantum walk on the integer line we use the Hadamard coin, but C can be either Hadamard coin, Grover coin or any other coin operator.

We can also look several steps ahead. We can express the quantum state $\ket{\psi}$ after $t$ time steps as
\begin{equation}
    \ket{\psi (t)} = U^t \ket{\psi(0)},
\end{equation}
where $\ket{\psi(0)}$ is the initial state and U is the operator for one step of the walk [1].

Coined quantum walks are most suitable for regular graphs, graphs where all nodes have the same number of neighbors [2]. An alternative quantum walk model that is more convenient for non-regular graphs is the Szegedy model that we will look at next.

### 2.B Szedgedy quantum walk
While a coined walk is a walk on the graph's nodes, a Szegedy walk is a walk on the edges on a bipartite double-cover of the original graph. The double-cover graph is a graph with twice as many vertices as the original graph. Two nodes in the bipartite double-cover graph are connected with an edge if and only if the nodes are also connected in the original graph. To create this model, we start with the transition probability matrix P for the classical walk. As described in section 1, we represent a classical discrete-time random walk by a transition matrix $P$. For any $N$-vertex graph with $N \times N$ transition matrix $P$, we can define the corresponding discrete-time quantum walk as a unitary operation on the Hilbert space $\mathcal{H}^N \otimes \mathcal{H}^N$. Let $P_{jk}$ define the probability that we make a transition from state $j$ to $k$. Before we define the walk, we define the normalized states

\begin{equation}
    \ket{\psi_j} := \sum_{k=1}^N \sqrt{P_{kj}} \ket{j,k}, \; j=1,...,N
\end{equation}

and the projection onto ${\ket{\psi_j}}:j=1,...,N$

\begin{equation}
    \Pi := \sum_{j=1}^N \ket{\psi_j} \bra{\psi_j}
\label{eq:sz_pi}
\end{equation}

We also introduce the shift operator S:

\begin{equation}
    S := \sum_{j,k=1}^N \ket{j,k} \bra{k,j}
\label{eq:sz_s}
\end{equation}

With $S$ and $\Pi$ defined as above we can introduce one step of the discrete-time quantum walk:

\begin{equation}
    U := S(2 \Pi - 1),
\label{eq:sz_op}
\end{equation}

where $(2 \Pi - 1)$ is the reflection operator. We also define $t$ steps of the walk as $U^t$ [2].

### 2.C Equivalence of coined and Szegedy quantum walks
It is known that a coined walk with Grover coin is equivalent to Szegedy quantum walk. For more details, we refer to this article [3] by Thomas G. Wong, where he also shows the equivalence between the operators in the two models.

## 3. Example: Implementing quantum walk on a hypercube
A hypercube is the $n$-dimensional counterpart of the $3$-dimensional cube. All nodes have degree $n$, and the hypercube has a total of $N=2^n$ nodes. We can represent the nodes in a hypercube graph by $n$-tuples of binary numbers. The binary representation of the neighbors of a node will differ by only one binary number. For example, in the 4-dimensional hypercube, the neighbors to $0000$ are $0001$, $0010$, $0100$, and $1000$. Thus, a node is connected to all nodes to which the Hamming distance is 1. The edges are also labeled. Two neighboring nodes that differ in the a:th bit are connected by the edge labeled $a$.

The Hilbert space representing a coined quantum walk on the hypercube is $\mathcal{H} = \mathcal{H}^n \otimes \mathcal{H}^{2^n}$, where $\mathcal{H}^n$ denotes the coin space and $\mathcal{H}^{2^n}$ the walker's position. The computational basis is

\begin{equation}
    \big\{ \ket{a,\vec{v}}, 0 \leq a \leq n-1, \vec{v} \in  \{(00...00), (00...01), ....., (11...11 )\} \big\}.
\end{equation}

The value of the coin computational basis $a$, which is associated with edge $a$, decides where the walker should move. If the $a=0$, the walker will go to the node where the first binary value differs from the current node. If $a=1$, the walker will move to the node in which the second value differs from the current, et cetera.  Let $\vec{e}_a$ be an n-tuple where all binary values, except the value with index $a$, are $0$. Then the shift operator $S$ moves the walker from the state $\ket{a} \ket{\vec{v}}$ to $\ket{\vec{v} \oplus \vec{e}_a}$:

\begin{equation}
    S \ket{a} \ket{\vec{v}} = \ket{a} \ket{\vec{v} \oplus \vec{e}_a}.
\end{equation}

We use the Grover coin, $G$, for this walk. Thus, the evolution operator is

\begin{equation}
    U = SG.
\end{equation}

We will now show how we can implement a quantum walk on a 4-dimensional hypercube. We need to implement the coin operator and the shift operator. We start by importing all necessary libraries from Qiskit.

```python
%matplotlib inline
# Importing standard Qiskit libraries
from qiskit import QuantumCircuit, execute, Aer, IBMQ, QuantumRegister, ClassicalRegister
from qiskit.compiler import transpile, assemble
from qiskit.tools.jupyter import *
from qiskit.visualization import *
from ibm_quantum_widgets import *
from qiskit.circuit.library import QFT
from numpy import pi
from qiskit.quantum_info import Statevector
from matplotlib import pyplot as plt
import numpy as np
# Loading your IBM Q account(s)
provider = IBMQ.load_account()
```

The circuit will have 6 qubits, 4 that represents the position and 2 that represents the coin. As we mentioned previously, the coin is a Grover coin, which is the diffuser in Grover's algorithm. We start by implementing this.

```python
one_step_circuit = QuantumCircuit(6, name=' ONE STEP')
# Coin operator
one_step_circuit.h([4,5])
one_step_circuit.z([4,5])
one_step_circuit.cz(4,5)
one_step_circuit.h([4,5])
one_step_circuit.draw()
```

Now, let us implement the shift operator. We know that the walker can only move to neighboring nodes, and all neighboring nodes differ by only one bit. We want to move the walker according to the coin, and we move the walker by applying a NOT gate to one of the node qubits. If the coin is in state $\ket{11}$, we move the walker to the state in which the first node qubit differ. If the coin is $\ket{10}$ or $\ket{01}$, the walker moves to the state where the second and third qubit, respectively, differ. Finally, if the Grover coin is $\ket{00}$, we flip the fourth qubit. We implement this with CCNOT- and NOT gates after the Grover coin. Together, they are one step of a quantum walk on a 4-dimensional hypercube.

```python
# Shift operator function for 4d-hypercube
def shift_operator(circuit):
    for i in range(0,4):
        circuit.x(4)
        if i%2==0:
            circuit.x(5)
        circuit.ccx(4,5,i)

shift_operator(one_step_circuit)

one_step_gate = one_step_circuit.to_instruction()
one_step_circuit.draw()
```

#### 4.Quantum walk search algorithm
We will now implement a quantum walk search algorithm that finds a marked vertex in a graph. First, we describe the algorithm, then we go through its implementation. The quantum walk search algorithm solves the problem of finding marked vertices in a graph by a quantum walk. That is, we mark some set of vertices $|M|$, start at an arbitrary node in the graph and move according to the walk until we find the marked nodes. The basis states in the quantum walk search algorithm have two registers, one corresponding to the current node and the other corresponding to the previous node. That is, the basis states corresponds to the edges in the graph. We denote the quantum walk based on the classical Markov chain with transition matrix $P$ by the unitary operation $W(P)$ on $\mathcal{H}$. We also define $\ket{p_x} = \sum_y \sqrt{P_{xy}}\ket{y}$ as the uniform superposition over the node $x$'s neighbors. Let $\ket{x}\ket{y}$ be a basis state. We define the basis state $\ket{x}\ket{y}$ as ''good'' if $x$ is a marked node. Otherwise, we refer to it as ''bad''. We now introduce ''good'' and ''bad'' states:

\begin{equation}
    \ket{G} = \frac{1}{\sqrt{|M|}} \sum_{x \in M} \ket{x} \ket{p_x}, \;
    \ket{B} = \frac{1}{\sqrt{N-|M|}} \sum_{x \notin M} \ket{x} \ket{p_x},
\end{equation}

which are the superpositions over good and bad basis states. Next, let us define $\epsilon = |M|/N$ and $\theta = \arcsin(\sqrt{\epsilon})$.

In short, the algorithm consists of three steps:

1. Set up the initial state $\ket{U} = \frac{1}{\sqrt{N}} \sum_{x} \ket{x} \ket{p_x} = \sin{\theta} \ket{G} + \cos{\theta} \ket{B}$, a uniform superposition over all edges
2. Repeat $O(1/\sqrt{\epsilon})$ times:

    (a) Reflect through $\ket{B}$

    (b) Reflect through $\ket{U}$

3. Do a measurement in the computational basis

We can easily implement step $1$ with Hadamard gates and the reflection through $\ket{B}$ with a phase oracle that shifts the phase of $x$ if $x$ is in the first register, and leaves the circuit unchanged otherwise.

Step 2(b) is equivalent to finding a unitary $R(P)$ that performs the following mapping:
\begin{align}
\label{eq:mapping_1}
    \ket{U} &\mapsto \ket{U}, \: \text{and} \\
    \ket{\psi} &\mapsto -\ket{\psi}, \: \forall \ket{\psi} \text{in the span of eigenvectors of $W(P)$ that are orthogonal to $\ket{U}$}
\label{eq:mapping_2}
\end{align}

To find this operator we apply phase estimation on $W(P)$. Above we defined $W(P)$ as the evolution operator for the random walk. As we saw in section 2.A, this is a unitary operator. From this follows that the eigenvalues of $W(P)$ have norm $1$. Because of this, we can write the eigenvalues of $W(P)$ on the form $e^{\pm 2i\theta_j}$. The unitary $W(P)$ has one eigenvector with corresponding eigenvalue $1$, which is $\ket{U}$. This is given by $\theta_1=0$. $R(P)$ will find this vector $\ket{U}$ by adding a register with auxiliary qubits and perform phase estimation with precision $O(1/\sqrt{\delta})$, where $\delta$ is the spectral gap of $P$. To do this, we need to apply $W(P)$ $O(1/\sqrt{\delta})$ times. Let $\ket{w}$ be an eigenvector of $W(P)$ with eigenvalue $e^{\pm 2i\theta_j}$. Assume that $\tilde{\theta_j}$ is the best approximation to $\theta_j$ given by the phase estimation. The operation $R(P)$ that performs the mappings in for $\ket{w}$ in step 2(b) is given by [4]

\begin{equation}
    \ket{w} \ket{0} \mapsto \ket{w} \ket{\tilde{\theta_j}} \mapsto (-1)^{|\tilde{\theta_j} \neq 0|} \ket{w} \ket{\tilde{\theta_j}} \mapsto (-1)^{|\tilde{\theta_j} \neq 0|} \ket{w} \ket{0}
\end{equation}

## 5.Example: Quantum walk search on 4-dimensional hypercube

The quantum walk search algorithm makes it possible to find a marked set of nodes in $O(1/\sqrt{\epsilon})$ steps, $\epsilon =  |M|/N$, where $M$ is the number of marked nodes and $N$ is the total number of nodes. This algorithm is originally used with Szegedy quantum walks, where we use two node registers to represent the quantum state. However, a coined walk with a Grover coin is equivalent to a Szegedy quantum walk and since implementations of coined walks are less complex in general, we choose to implement the algorithm with a coined walk. We will use the 4-dimensional hypercube that we implemented in section 3.

In short, we will implement the algorithm as follows. We achieve step 1, a uniform superposition over all edges, by applying Hadamard gates to the node qubits as well as the coin qubits. For step 2(a), we implement a phase oracle. Step 2(b) is implemented by a phase estimation on one step of the quantum walk on the hypercube followed by marking all quantum states where $\theta \neq 0$. We do this by rotating an auxiliary qubit. In the last part of this step, we reverse the phase estimation. The number of theta qubits depends on the precision of $\theta$.

Below, we implement the quantum walk search algorithm on the 4-dimensional hypercube.

For this algorithm we will need to use the inverse of the one step gate implemented earlier. We get this by using the built in circuit function inverse().

```python
one_step_circuit.inverse().draw()
```

The inversed one step gate will be used to reverse the phase estimation later. We need to make controlled gates from both the one step gate that we implemented in section 3 and its inverse. We will later use them depending on the value of the control qubit.

```python
# Make controlled gates
inv_cont_one_step = one_step_circuit.inverse().control()
inv_cont_one_step_gate = inv_cont_one_step.to_instruction()
cont_one_step = one_step_circuit.control()
cont_one_step_gate = cont_one_step.to_instruction()
```

Both the controlled one step gate and the controlled inversed one step gate will be used in the phase estimation. Another thing we will use in the phase estimation is the Quantum Fourier Transform. Qiskit has a function, QFT, which implements the Quantum Fourier Transform. The phase estimation uses the inverse Quantum Fourier Transform but we also will need to use the ordinary QFT to reverse the phase estimation.

```python
inv_qft_gate = QFT(4, inverse=True).to_instruction()
qft_gate = QFT(4, inverse=False).to_instruction()

QFT(4, inverse=True).decompose().draw("mpl")
```

Before we implement the phase estimation, we implement a phase oracle that marks the states 1011 and 1111. Then, we make it a circuit. This is step 2(a) of the algorithm.

```python
phase_circuit =  QuantumCircuit(6, name=' phase oracle ')
# Mark 1011
phase_circuit.x(2)
phase_circuit.h(3)
phase_circuit.mct([0,1,2], 3)
phase_circuit.h(3)
phase_circuit.x(2)
# Mark 1111
phase_circuit.h(3)
phase_circuit.mct([0,1,2],3)
phase_circuit.h(3)
phase_oracle_gate = phase_circuit.to_instruction()
# Phase oracle circuit
phase_oracle_circuit =  QuantumCircuit(11, name=' PHASE ORACLE CIRCUIT ')
phase_oracle_circuit.append(phase_oracle_gate, [4,5,6,7,8,9])
phase_circuit.draw()
```

We will now implement a gate that rotates an auxiliary qubit if the other qubits are non-zero. We will use this gate in the phase estimation, where it will rotate the auxiliary qubit if $\theta \neq 0$.

```python
# Mark q_4 if the other qubits are non-zero
mark_auxiliary_circuit = QuantumCircuit(5, name=' mark auxiliary ')
mark_auxiliary_circuit.x([0,1,2,3,4])
mark_auxiliary_circuit.mct([0,1,2,3], 4)
mark_auxiliary_circuit.z(4)
mark_auxiliary_circuit.mct([0,1,2,3], 4)
mark_auxiliary_circuit.x([0,1,2,3,4])

mark_auxiliary_gate = mark_auxiliary_circuit.to_instruction()
mark_auxiliary_circuit.draw()
```

Now, we will implement step 2(b) of the algorithm. This step consists of a phase estimation one step of the quantum walk followed by an auxiliary qubit that we rotate if $\theta \neq 0$. For this, we use the mark_auxiliary_gate that we just created.  Thereafter, we reverse the phase estimation.

```python
# Phase estimation
phase_estimation_circuit = QuantumCircuit(11, name=' phase estimation ')
phase_estimation_circuit.h([0,1,2,3])
for i in range(0,4):
    stop = 2**i
    for j in range(0,stop):
        phase_estimation_circuit.append(cont_one_step, [i,4,5,6,7,8,9])

# Inverse fourier transform
phase_estimation_circuit.append(inv_qft_gate, [0,1,2,3])

# Mark all angles theta that are not 0 with an auxiliary qubit
phase_estimation_circuit.append(mark_auxiliary_gate, [0,1,2,3,10])

# Reverse phase estimation
phase_estimation_circuit.append(qft_gate, [0,1,2,3])

for i in range(3,-1,-1):
    stop = 2**i
    for j in range(0,stop):
        phase_estimation_circuit.append(inv_cont_one_step, [i,4,5,6,7,8,9])
phase_estimation_circuit.barrier(range(0,10))
phase_estimation_circuit.h([0,1,2,3])

# Make phase estimation gate
phase_estimation_gate = phase_estimation_circuit.to_instruction()
phase_estimation_circuit.draw()
```

Now we implement the whole quantum walk search algorithm using the gates we made previously. We start by applying Hadamard gates to node and coin qubits, which is step 1 in the algorithm. Thereafter, we iteratively apply the phase oracle gate and the phase estimation gate (steps 2(a) and 2(b)). We need $O(1/\sqrt{\epsilon})$ iterations as stated in the description of the algorithm in section 4. Lastly, we measure the node qubits.

```python
# Implementation of the full quantum walk search algorithm
theta_q = QuantumRegister(4, 'theta')
node_q = QuantumRegister(4, 'node')
coin_q = QuantumRegister(2, 'coin')
auxiliary_q = QuantumRegister(1, 'auxiliary')
creg_c2 = ClassicalRegister(4, 'c')
circuit = QuantumCircuit(theta_q, node_q, coin_q, auxiliary_q, creg_c2)
# Apply Hadamard gates to the qubits that represent the nodes and the coin
circuit.h([4,5,6,7,8,9])
iterations = 2

for i in range(0,iterations):
    circuit.append(phase_oracle_gate, [4,5,6,7,8,9])
    circuit.append(phase_estimation_gate, [0,1,2,3,4,5,6,7,8,9,10])

circuit.measure(node_q[0], creg_c2[0])
circuit.measure(node_q[1], creg_c2[1])
circuit.measure(node_q[2], creg_c2[2])
circuit.measure(node_q[3], creg_c2[3])
circuit.draw()
```

Finally we run the implementation on the qasm simulator. We see that the circuit collapse to the marked states a clear majority of the times.

```python
backend = Aer.get_backend('qasm_simulator')
job = execute( circuit, backend, shots=1024 )
hist = job.result().get_counts()
plot_histogram( hist )
```

## 6. References

1. Renato Portugal. Quantum Walks and Search Algorithms. New York, NY: Springer New York, 2013
2. Markus G. Kuhn.Some Introductory Notes on Quantum Computing. Apr. 2000
3. Thomas G. Wong. “Equivalence of Szegedy’s and coined quantum walks”. In: Quantum InformationProcessing 16.9 (July 2017). ISSN: 1573-1332. DOI:10.1007/s11128-017-1667-y. URL:http://dx.doi.org/10.1007/s11128-017-1667-y.37
4. Ronald de Wolf. Quantum Computing: Lecture Notes. 2021. arXiv:1907.09415 [quant-ph]

```python

```


###### Content under Creative Commons Attribution license CC-BY 4.0, code under MIT license (c)2014 L.A. Barba, C.D. Cooper, G.F. Forsyth.

# Reaction-diffusion model

This IPython Notebook presents the context and set-up for the coding assignment of Module 4: _Spreading out: Diffusion problems_, of the course [**"Practical Numerical Methods with Python"**](https://github.com/numerical-mooc/numerical-mooc) (a.k.a., numericalmooc).

So far in this module, we've studied diffusion in 1D and 2D. Now it's time to add in some more interesting physics.  You'll study a model represented by *reaction-diffusion* equations.  What are they?  The name says it all—it's a system that has the physics of diffusion but also has some kind of reaction that adds different behaviors to the solution.

We're going to look at the _Gray-Scott model_, which simulates the interaction of two generic chemical species reacting and ... you guessed it ... diffusing! Some amazing patterns can emerge with simple reaction models, eerily reminiscent of patterns formed in nature. It's fascinating! Check out this simulation by Karl Sims posted on You Tube ... it looks like a growing coral reef, doesn't it?

```
from IPython.display import YouTubeVideo
YouTubeVideo('8dTmUr5qKvI')
```

## Gray-Scott model

The Gray-Scott model represents the reaction and diffusion of two generic chemical species, $U$ and $V$, whose concentration at a point in space is represented by variables $u$ and $v$. The model follows some simple rules.

*  Each chemical _diffuses_ through space at its own rate.
*  Species $U$ is added at a constant feed rate into the system.
*  Two units of species V can 'turn' a unit of species U into V: $\; 2V+U\rightarrow 3V$
*  There's a constant kill rate removing species $V$.

This model results in the following system of partial differential equations for the concentrations $u(x,y,t)$ and $v(x,y,t)$ of both chemical species:

\begin{align}
\frac{\partial u}{\partial t} &= D_u \nabla ^2 u - uv^2 + F(1-u)\\
\frac{\partial v}{\partial t} &= D_v \nabla ^2 v + uv^2 - (F + k)v
\end{align}

You should see some familiar terms, and some unfamiliar ones.  On the left-hand side of each equation, we have the time rate of change of the concentrations. The first term on the right of each equation correspond to the spatial diffusion of each concentration, with $D_u$ and $D_v$ the respective rates of diffusion.

In case you forgot, the operator $\nabla ^2$ is the Laplacian:

$$
\nabla ^2 u = \frac{\partial ^2 u}{\partial x^2} + \frac{\partial ^2 u}{\partial y^2}
$$

The second term on the right-hand side of each equation corresponds to the reaction. You see that this term decreases $u$ while it increases $v$ in the same amount: $uv^2$.  The reaction requires one unit of $U$ and two units of $V$,  resulting in a reaction rate proportional to the concentration $u$ and to the square of the concentration $v$. This result derives from the _law of mass action_, which we can explain in terms of probability: the odds of finding one molecule of species $U$ at a point in space is proportional to the concentration $u$, while the odds of finding two molecules of $V$ is proportional to the concentration squared, $v^2$. We assume here a reaction rate constant equal to $1$, which just means that the model is non-dimensionalized in some way.

The final terms in the two equations are the "feed" and "kill" rates, respectively: $F(1-u)$ replenishes the species $U$ (which would otherwise run out, as it is being turned into $V$ by the reaction); $-(F+k)v$ is diminishing the species $V$ (otherwise the concentration $v$ would simply increase without bound).

The values of $F$ and $k$ are chosen parameters and part of the fun of this assignment is to change these values, together with the diffusion constants, and see what happens.

### Problem setup

The system is represented by two arrays, `U` and `V`, holding the discrete values of the concentrations $u$ and $v$, respectively.  We start by setting `U = 1` everywhere and `V = 0` everywhere, then introduce areas of difference, as initial conditions.  We then add a little noise to the whole system to help the $u$ and $v$ reactions along.

Below is the code segment we used to generate the initial conditions for `U` and `V`.

**NOTE**: *DO NOT USE THIS CODE IN YOUR ASSIGNMENT*.
We are showing it here to help you understand how the system is constructed.  However, you _must use the data we've supplied below_ as your starting condition or your answers will not match those that the grading system expects.

```[Python]
num_blocks = 30
randx = numpy.random.randint(1, nx-1, num_blocks)
randy = numpy.random.randint(1, nx-1, num_blocks)
U = numpy.ones((n,n))
V = numpy.zeros((n,n))

r = 10
U[:,:] = 1.0

for i, j in zip(randx, randy):
    U[i-r:i+r,j-r:j+r] = 0.50
    V[i-r:i+r,j-r:j+r] = 0.25

U += 0.05*numpy.random.random((n,n))
V += 0.05*numpy.random.random((n,n))
```

## Your assignment

*  Discretize the reaction-diffusion equations using forward-time/central-space and assume that $\Delta x = \Delta y = \delta$.

*  For your timestep, set
$$\Delta t = \frac{9}{40}\frac{\delta^2}{\max(D_u, D_v)}$$

*  Use zero Neumann boundary conditions on all sides of the domain.

You should use the initial conditions and constants listed in the cell below.  They correspond to the following domain:

*  Grid of points with dimension `192x192` points
*  Domain is $5{\rm m} \times 5{\rm m}$
*  Final time is $8000{\rm s}$.

```
import numpy
import matplotlib.pyplot as plt
import matplotlib.cm as cm
%matplotlib inline
```

```
n = 192

Du, Dv, F, k = 0.00016, 0.00008, 0.035, 0.065 # Bacteria 1

dh = 5./(n-1)

T = 8000

dt = .9 * dh**2 / (4*max(Du,Dv))

nt = int(T/dt)

```

### Initial condition data files

In order to ensure that you start from the same initial conditions as we do, please download the file [uvinitial.npz](https://github.com/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/data/uvinitial.npz?raw=true)

This is a NumPy save-file that contains two NumPy arrays, holding the initial values for `U` and `V`, respectively.  Once you have downloaded the file into your working directory, you can load the data using the following code snippet.

```
uvinitial = numpy.load('./data/uvinitial.npz')
U = uvinitial['U']
V = uvinitial['V']
```

```
fig = plt.figure(figsize=(8,5))
plt.subplot(121)
plt.imshow(U, cmap = cm.RdBu)
plt.xticks([]), plt.yticks([]);
plt.subplot(122)
plt.imshow(V, cmap = cm.RdBu)
plt.xticks([]), plt.yticks([]);

```

## Sample Solution

Below is an animated gif showing the results of this solution for a different set of randomized initial block positions.  Each frame of the animation represents 100 timesteps.

Just to get your juices flowing!

## Exploring extra patterns

Once you have completed the assignment, you might want to explore a few more of the interesting patterns that can be obtained with the Gray-Scott model.  The conditions below will result in a variety of patterns and should work without any other changes to your existing code.

This pattern is called "Fingerprints."

```
#Du, Dv, F, k = 0.00014, 0.00006, 0.035, 0.065 # Bacteria 2
#Du, Dv, F, k = 0.00016, 0.00008, 0.060, 0.062 # Coral
#Du, Dv, F, k = 0.00019, 0.00005, 0.060, 0.062 # Fingerprint
#Du, Dv, F, k = 0.00010, 0.00010, 0.018, 0.050 # Spirals
#Du, Dv, F, k = 0.00012, 0.00008, 0.020, 0.050 # Spirals Dense
#Du, Dv, F, k = 0.00010, 0.00016, 0.020, 0.050 # Spirals Fast
#Du, Dv, F, k = 0.00016, 0.00008, 0.020, 0.055 # Unstable
#Du, Dv, F, k = 0.00016, 0.00008, 0.050, 0.065 # Worms 1
#Du, Dv, F, k = 0.00016, 0.00008, 0.054, 0.063 # Worms 2
#Du, Dv, F, k = 0.00016, 0.00008, 0.035, 0.060 # Zebrafish
```

## References

*  Reaction-diffusion tutorial, by Karl Sims
http://www.karlsims.com/rd.html

*  Pearson, J. E. (1993). [Complex patterns in a simple system](http://www.sciencemag.org/content/261/5118/189), _Science_, Vol. 261(5118), 189-192 // [PDF](http://www3.nd.edu/~powers/pearson.pdf) from nd.edu.

*  Pattern Parameters from [http://www.aliensaint.com/uo/java/rd/](http://www.aliensaint.com/uo/java/rd/)

---
###### The cell below loads the style of the notebook

```
from IPython.core.display import HTML
css_file = '../../styles/numericalmoocstyle.css'
HTML(open(css_file, "r").read())
```

<link href='http://fonts.googleapis.com/css?family=Alegreya+Sans:100,300,400,500,700,800,900,100italic,300italic,400italic,500italic,700italic,800italic,900italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Arvo:400,700,400italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=PT+Mono' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Shadows+Into+Light' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Nixie+One' rel='stylesheet' type='text/css'>
<style>

@font-face {
    font-family: "Computer Modern";
    src: url('http://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf');
}

#notebook_panel { /* main background */
    background: rgb(245,245,245);
}

div.cell { /* set cell width */
    width: 750px;
}

div #notebook { /* centre the content */
    background: #fff; /* white background for content */
    width: 1000px;
    margin: auto;
    padding-left: 0em;
}

#notebook li { /* More space between bullet points */
margin-top:0.8em;
}

/* draw border around running cells */
div.cell.border-box-sizing.code_cell.running {
    border: 1px solid #111;
}

/* Put a solid color box around each cell and its output, visually linking them*/
div.cell.code_cell {
    background-color: rgb(256,256,256);
    border-radius: 0px;
    padding: 0.5em;
    margin-left:1em;
    margin-top: 1em;
}

div.text_cell_render{
    font-family: 'Alegreya Sans' sans-serif;
    line-height: 140%;
    font-size: 125%;
    font-weight: 400;
    width:600px;
    margin-left:auto;
    margin-right:auto;
}

/* Formatting for header cells */
.text_cell_render h1 {
    font-family: 'Nixie One', serif;
    font-style:regular;
    font-weight: 400;
    font-size: 45pt;
    line-height: 100%;
    color: rgb(0,51,102);
    margin-bottom: 0.5em;
    margin-top: 0.5em;
    display: block;
}
.text_cell_render h2 {
    font-family: 'Nixie One', serif;
    font-weight: 400;
    font-size: 30pt;
    line-height: 100%;
    color: rgb(0,51,102);
    margin-bottom: 0.1em;
    margin-top: 0.3em;
    display: block;
}

.text_cell_render h3 {
    font-family: 'Nixie One', serif;
    margin-top:16px;
	font-size: 22pt;
    font-weight: 600;
    margin-bottom: 3px;
    font-style: regular;
    color: rgb(102,102,0);
}

.text_cell_render h4 {    /*Use this for captions*/
    font-family: 'Nixie One', serif;
    font-size: 14pt;
    text-align: center;
    margin-top: 0em;
    margin-bottom: 2em;
    font-style: regular;
}

.text_cell_render h5 {  /*Use this for small titles*/
    font-family: 'Nixie One', sans-serif;
    font-weight: 400;
    font-size: 16pt;
    color: rgb(163,0,0);
    font-style: italic;
    margin-bottom: .1em;
    margin-top: 0.8em;
    display: block;
}

.text_cell_render h6 { /*use this for copyright note*/
    font-family: 'PT Mono', sans-serif;
    font-weight: 300;
    font-size: 9pt;
    line-height: 100%;
    color: grey;
    margin-bottom: 1px;
    margin-top: 1px;
}

.CodeMirror{
        font-family: "PT Mono";
        font-size: 90%;
}

</style>


# Introduction to Deep Learning with PyTorch

In this notebook, you'll get introduced to [PyTorch](http://pytorch.org/), a framework for building and training neural networks. PyTorch in a lot of ways behaves like the arrays you love from Numpy. These Numpy arrays, after all, are just tensors. PyTorch takes these tensors and makes it simple to move them to GPUs for the faster processing needed when training neural networks. It also provides a module that automatically calculates gradients (for backpropagation!) and another module specifically for building neural networks. All together, PyTorch ends up being more coherent with Python and the Numpy/Scipy stack compared to TensorFlow and other frameworks.

## Neural Networks

Deep Learning is based on artificial neural networks which have been around in some form since the late 1950s. The networks are built from individual parts approximating neurons, typically called units or simply "neurons." Each unit has some number of weighted inputs. These weighted inputs are summed together (a linear combination) then passed through an activation function to get the unit's output.

Mathematically this looks like:

$$
\begin{align}
y &= f(w_1 x_1 + w_2 x_2 + b) \\
y &= f\left(\sum_i w_i x_i +b \right)
\end{align}
$$

With vectors this is the dot/inner product of two vectors:

$$
h = \begin{bmatrix}
x_1 \, x_2 \cdots  x_n
\end{bmatrix}
\cdot
\begin{bmatrix}
           w_1 \\
           w_2 \\
           \vdots \\
           w_n
\end{bmatrix}
$$

## Tensors

It turns out neural network computations are just a bunch of linear algebra operations on *tensors*, a generalization of matrices. A vector is a 1-dimensional tensor, a matrix is a 2-dimensional tensor, an array with three indices is a 3-dimensional tensor (RGB color images for example). The fundamental data structure for neural networks are tensors and PyTorch (as well as pretty much every other deep learning framework) is built around tensors.

With the basics covered, it's time to explore how we can use PyTorch to build a simple neural network.

```python
# First, import PyTorch
import torch
```

```python
def activation(x):
    """ Sigmoid activation function

        Arguments
        ---------
        x: torch.Tensor
    """
    # instead of using np.exp, we use torch.exp
    return 1/(1+torch.exp(-x))
```

```python
### Generate some data
torch.manual_seed(7) # Set the random seed so things are predictable

# Features are 5 random normal variables
features = torch.randn((1, 5))
# True weights for our data, random normal variables again
weights = torch.randn_like(features)
# and a true bias term
bias = torch.randn((1, 1))
```

**Lecture Note:**

```torch.randn(size=(?, ?))```: creates a tensor from a normal distribution.

```torch.randn_like(input=?)```: we pass a tensor to it and ```randn_like``` looks at the shape of the *input tensor* then create another tensor with the same shape.

Above I generated data we can use to get the output of our simple network. This is all just random for now, going forward we'll start using normal data. Going through each relevant line:

`features = torch.randn((1, 5))` creates a tensor with shape `(1, 5)`, one row and five columns, that contains values randomly distributed according to the normal distribution with a mean of zero and standard deviation of one.

`weights = torch.randn_like(features)` creates another tensor with the same shape as `features`, again containing values from a normal distribution.

Finally, `bias = torch.randn((1, 1))` creates a single value from a normal distribution.

PyTorch tensors can be added, multiplied, subtracted, etc, just like Numpy arrays. In general, you'll use PyTorch tensors pretty much the same way you'd use Numpy arrays. They come with some nice benefits though such as GPU acceleration which we'll get to later. For now, use the generated data to calculate the output of this simple single layer network.
> **Exercise**: Calculate the output of the network with input features `features`, weights `weights`, and bias `bias`. Similar to Numpy, PyTorch has a [`torch.sum()`](https://pytorch.org/docs/stable/torch.html#torch.sum) function, as well as a `.sum()` method on tensors, for taking sums. Use the function `activation` defined above as the activation function.

```python
## Calculate the output of this network using the weights and bias tensors
y = activation(torch.sum(features * weights)  + bias)
print(y)
```

    tensor([[0.1595]])

You can do the multiplication and sum in the same operation using a matrix multiplication. In general, you'll want to use matrix multiplications since they are more efficient and accelerated using modern libraries and high-performance computing on GPUs.

Here, we want to do a matrix multiplication of the features and the weights. For this we can use [`torch.mm()`](https://pytorch.org/docs/stable/torch.html#torch.mm) or [`torch.matmul()`](https://pytorch.org/docs/stable/torch.html#torch.matmul) which is somewhat more complicated and supports broadcasting. If we try to do it with `features` and `weights` as they are, we'll get an error

```python
>> torch.mm(features, weights)

---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
<ipython-input-13-15d592eb5279> in <module>()
----> 1 torch.mm(features, weights)

RuntimeError: size mismatch, m1: [1 x 5], m2: [1 x 5] at /Users/soumith/minicondabuild3/conda-bld/pytorch_1524590658547/work/aten/src/TH/generic/THTensorMath.c:2033
```

```python
# try this out
torch.mm(features, weights)
```

**Lecture Note:**

Difference between ``` torch.mm()``` and ```torch.matmul()``` for matrix multiplication:

1. torch.mm()

More simple and more **strict** about the input tensors that are passed in.

**So torch.mm() is recommended.**

2. torch.matmul()

Support broadcasting. It may generate non-expecting output if you pass tensors in weird  shapes.

As you're building neural networks in any framework, you'll see this often. Really often. What's happening here is our tensors aren't the correct shapes to perform a matrix multiplication. Remember that for matrix multiplications, the number of columns in the first tensor must equal to the number of rows in the second column. Both `features` and `weights` have the same shape, `(1, 5)`. This means we need to change the shape of `weights` to get the matrix multiplication to work.

**Note:** To see the shape of a tensor called `tensor`, use **`tensor.shape`**. If you're building neural networks, you'll be using this method often.

There are a few options here: [`weights.reshape()`](https://pytorch.org/docs/stable/tensors.html#torch.Tensor.reshape), [`weights.resize_()`](https://pytorch.org/docs/stable/tensors.html#torch.Tensor.resize_), and [`weights.view()`](https://pytorch.org/docs/stable/tensors.html#torch.Tensor.view).

* `weights.reshape(a, b)` will return a new tensor with the same data as `weights` with size `(a, b)` sometimes, and sometimes a clone, as in it copies the data to another part of memory.
* `weights.resize_(a, b)` returns the same tensor with a different shape. However, if the new shape results in fewer elements than the original tensor, some elements will be removed from the tensor (but not from memory). If the new shape results in more elements than the original tensor, new elements will be uninitialized in memory. Here I should note that the underscore at the end of the method denotes that this method is performed **in-place**. Here is a great forum thread to [read more about in-place operations](https://discuss.pytorch.org/t/what-is-in-place-operation/16244) in PyTorch.
* `weights.view(a, b)` will return a new tensor with the same data as `weights` with size `(a, b)`.

I usually use `.view()`, but any of the three methods will work for this. So, now we can reshape `weights` to have five rows and one column with something like `weights.view(5, 1)`.

> **Exercise**: Calculate the output of our little network using matrix multiplication.

**Lecture Note:**

Difference between 3 ways in changing tensor's shape:

1. ```weights.reshape()```

return a new tensor with the same data as weights. The actual data in memory isn't being changed. **However**, sometimes it does clone the tensor in some case.

2. ```weights.resize_()```

the underscore (_) means that this method is an *in-place operation*. So this function changes the tensor that's sitting on top of the address in memory.

Potential problem with `resize_`: If you request a shape that has more or less elements than the original tensor, it will cut off and you will **lose some of the data in the original tensor. So be careful.**

3. ```weights.view()```

return a new tensor with the same data in memory as weights. If you request a new shape with a different number of elements in the original tensor, it will return an error instead of cutting off as done in `resize_`.

**```weights.view()``` is recommended.**

```python
print(features.shape)
print(weights.shape)
```

    torch.Size([1, 5])
    torch.Size([1, 5])

```python
## Calculate the output of this network using matrix multiplication
y = activation(torch.mm(features, weights.view(5, 1)) + bias)
print(y)
```

    tensor([[0.1595]])

### Stack them up!

That's how you can calculate the output for a single neuron. The real power of this algorithm happens when you start stacking these individual units into layers and stacks of layers, into a network of neurons. The output of one layer of neurons becomes the input for the next layer. With multiple input units and output units, we now need to express the weights as a matrix.

The first layer shown on the bottom here are the inputs, understandably called the **input layer**. The middle layer is called the **hidden layer**, and the final layer (on the right) is the **output layer**. We can express this network mathematically with matrices again and use matrix multiplication to get linear combinations for each unit in one operation. For example, the hidden layer ($h_1$ and $h_2$ here) can be calculated

$$
\vec{h} = [h_1 \, h_2] =
\begin{bmatrix}
x_1 \, x_2 \cdots \, x_n
\end{bmatrix}
\cdot
\begin{bmatrix}
           w_{11} & w_{12} \\
           w_{21} &w_{22} \\
           \vdots &\vdots \\
           w_{n1} &w_{n2}
\end{bmatrix}
$$

The output for this small network is found by treating the hidden layer as inputs for the output unit. The network output is expressed simply

$$
y =  f_2 \! \left(\, f_1 \! \left(\vec{x} \, \mathbf{W_1}\right) \mathbf{W_2} \right)
$$

**Lecture Note:**

In the image above,

1. input vector $[x_1, x_2, x_3]$ dot product with the first column in orange in weights -> get the result (activation/weighted sum) for $h_1$

2. input vector $[x_1, x_2, x_3]$ dot product with the second column in black in weights -> get the result (activation/weighted sum) for $h_2$

In the formula above:

1. $W_1$ indicates the weights between the layer 1 and layer 2 (as I call it $W^{(1)}$)

2. $W_2$ indicates the weights between the layer 2 and layer 3 (as I call it $W^{(2)}$)

```python
### Generate some data
torch.manual_seed(7) # Set the random seed so things are predictable

# Features are 3 random normal variables
features = torch.randn((1, 3))

# Define the size of each layer in our network
n_input = features.shape[1]     # Number of input units, must match number of input features
n_hidden = 2                    # Number of hidden units
n_output = 1                    # Number of output units

# Weights for inputs to hidden layer
W1 = torch.randn(n_input, n_hidden)
# Weights for hidden layer to output layer
W2 = torch.randn(n_hidden, n_output)

# and bias terms for hidden and output layers
B1 = torch.randn((1, n_hidden))
B2 = torch.randn((1, n_output))
```

> **Exercise:** Calculate the output for this multi-layer network using the weights `W1` & `W2`, and the biases, `B1` & `B2`.

```python
## Your solution here
# layer 1 (hidden layer) weighted sum
layer_1 = torch.mm(features, W1) + B1
# layer 1 output
layer_1_out = activation(layer_1)
# layer 2(output layer) weighted sum
layer_2 = torch.mm(layer_1_out, W2) + B2
# layer 2 output
y = activation(layer_2)

print(y)
```

    tensor([[0.3171]])

If you did this correctly, you should see the output `tensor([[ 0.3171]])`.

The number of hidden units a parameter of the network, often called a **hyperparameter** to differentiate it from the weights and biases parameters. As you'll see later when we discuss training a neural network, the more hidden units a network has, and the more layers, the better able it is to learn from data and make accurate predictions.

## Numpy to Torch and back

Special bonus section! PyTorch has a great feature for converting between Numpy arrays and Torch tensors. To create a tensor from a Numpy array, use `torch.from_numpy()`. To convert a tensor to a Numpy array, use the `.numpy()` method.

**Lecture Note:**

We usually do data preprocessing in NumPy and bridge the NumPy to Torch tensors that are used in networks.

1. ```torch.from_numpy(a)``` converts a NumPy array to a Torch tensor

2. ```numpy()``` converts a Torch tensor to a NumPy array

Note that the Torch tensor and converted NumPy array (or vice versa) **share the same memory**. If you change on either one of them, you actually change BOTH!

```python
import numpy as np
a = np.random.rand(4,3)
a
```

    array([[0.06720061, 0.77096793, 0.83298826],
           [0.34892585, 0.41232721, 0.23292744],
           [0.74813679, 0.08177304, 0.65275816],
           [0.57799001, 0.15149484, 0.07117177]])

```python
b = torch.from_numpy(a)
b
```

    tensor([[0.0672, 0.7710, 0.8330],
            [0.3489, 0.4123, 0.2329],
            [0.7481, 0.0818, 0.6528],
            [0.5780, 0.1515, 0.0712]], dtype=torch.float64)

```python
b.numpy()
```

    array([[0.06720061, 0.77096793, 0.83298826],
           [0.34892585, 0.41232721, 0.23292744],
           [0.74813679, 0.08177304, 0.65275816],
           [0.57799001, 0.15149484, 0.07117177]])

The memory is shared between the Numpy array and Torch tensor, so if you change the values in-place of one object, the other will change as well.

```python
# Multiply PyTorch Tensor by 2, in place
b.mul_(2)
```

    tensor([[0.1344, 1.5419, 1.6660],
            [0.6979, 0.8247, 0.4659],
            [1.4963, 0.1635, 1.3055],
            [1.1560, 0.3030, 0.1423]], dtype=torch.float64)

```python
# Numpy array matches new values from Tensor
a
```

    array([[0.13440121, 1.54193585, 1.66597653],
           [0.69785171, 0.82465443, 0.46585488],
           [1.49627357, 0.16354609, 1.30551632],
           [1.15598002, 0.30298967, 0.14234353]])

```python

```

exercise_notebooks_my_solutions/2. Neural Networks/intro-to-pytorch/Part 1 - Tensors in PyTorch (Exercises).ipynb