# 3. Quantum mechanics

Quantum mechanics is a theory which describes physical phenomena. The ideas behind quantum mechanics often appear counter intuitive and in conflict with our everyday experience of the world but they are particularly useful for understanding the behavior of particles at small scales, such as atoms and molecules. In fact, it was at these rather small scales that the breakdown of the classical laws of physics was first encountered. Nonetheless, the realm of quantum mechanics is not confined to the atomic world but it extends almost to all known scales (some problems arise in the interior of black holes and other exotic places for example). When dealing with objects of macroscopic size, the predictions of quantum mechanics agree with the one which can be made using classical laws of physics such as Newtonian mechanics and Maxwell's equations of the electromagnetic field.

In the previous chapter, we encountered almost all the mathematical machinery needed to do calculations in quantum mechanics. In fact, in quantum mechanics physical systems and the interaction between them can be described using the matrix representation of the Hilbert space formalism. Here, we will make a connection between the mathematical concepts introduced and their relation to the physical world. The mathematics works extremely well, it has been tested countless times over the years without ever being unsuccessful in describing experiments. What has turned out to be very difficult is to associate an interpretation to the meaning of these mathematical abstractions. We will give here the standard and most widespread interpretation of the connection between the mathematical framework of quantum mechanics to our physical reality.

## 3.1 Postulates of quantum mechanics

Here, we present quantum mechanics as a tool for a new paradigm of computing. Thus, we will introduce it in an axiomatic way and quickly put it to use. First, we introduce the axioms of the theory, then we describe the connection to the physical interpretation of these abstract concepts.
Let us briefly go over the axioms, or postulates, of quantum mechanics. These make up the mathematical foundation of the theory.

 All the possible states of a system form an Hilbert space.

 The wave function $\lvert \psi \rangle$, which is a ket vector in the Hilbert space, encodes the state of the system. Complex linear combinations of the states of the system are still valid states of the system.

 Physical properties of a system, called observables, are encoded into the real eigenvalues of Hermitian operators on a Hilbert space.

 The evolution of the system in time is generated by a particular operator, the Hamiltonian. The Hamiltonian is the operator associated with the total energy of the system.

 The expected value of a physical property $\hat{F}$ of the system in the state $\lvert \psi \rangle $ is found by calculating $ \langle \psi \rvert \hat{F} \lvert \psi \rangle$.

Using the rules given above one can make accurate predictions about properties of interest in any given setting.

#### Example
For example, consider a two-level system, a qubit, let us see how the postulates are used in this simple case.
The possible state of the qubit are either high/low (on/off, 1/0, up/down,..), so the Hilbert space is just made up by these two vectors $\lvert 0 \rangle$, $\lvert 1 \rangle$. One usually says that these form a "basis" for the Hilbert space, as any vector in the space can be found as a complex linear combination of these two.

The state of the qubit is represented by a vector in this Hilbert space. The most general vector is $\lvert \psi \rangle = \alpha \lvert 0 \rangle + \beta \lvert 1 \rangle $.

Consider the Hamiltonian $\hat{H} = E_0 \hat{\sigma}^{+}\hat{\sigma}^{-}$ for the system, where $\hat{\sigma}^{+} \lvert 0 \rangle = \lvert 1 \rangle $, $\hat{\sigma}^{+} \lvert 1 \rangle = 0 $ and $\hat{\sigma}^{-} \lvert 1 \rangle = \lvert 0 \rangle $, $\hat{\sigma}^{-} \lvert 0 \rangle = 0 $.

The Hamiltonian encodes the energy of the system. Therefore, we can calculate the energy of the qubit in the state $\lvert 0 \rangle$: $E = \langle 0 \rvert \hat{H} \lvert 0 \rangle = 0$.
On the other hand, if the qubit is in the "on" state $\lvert 1 \rangle$, its energy is: $E = \langle 1 \rvert \hat{H} \lvert 1 \rangle = E_0$.

## 3.2 The wave function

The state of a system is described by a ket vector in Hilbert space, called wave function $\vert \psi\left( \vec{r} , t \right)\rangle $, where the vector $\vec{r}$ specifies the position at time $t$. The wave function has the following properties:

 It is a complex function of $\vec{r}$ and $t$.

 It is a continuous function.

 Generally its derivative is also a continuous function.

 Its modulo square, $\left\vert \psi \right\vert ^{2}$, is integrable and has physical meaning as the probability of observing the system in the state represented by $\lvert \psi \rangle$.

The wave function, being a ket vector, inherits the superposition principle. Therefore, it is possible to build any wave function as a weighted combination of other wave functions.

 If the system can be both in the state $\vert \psi_1 \rangle $ and in state $\vert \psi_2 \rangle $, then the total state of the system can be written as: $\vert \psi \rangle = \alpha_1 \lvert \psi_1 \rangle + \alpha_2 \lvert \psi_2 \rangle $,
where $\alpha_1$, $\alpha_2$ are complex numbers whose modulo square represent the probability of finding the system in the corresponding states.

 If the wave function is multiplied by any number $\alpha \neq 0$, the state described by the corresponding wave function will not change: $\vert \psi \rangle \leftrightarrow \alpha \lvert \psi \rangle$

The fact that the superposition principle applies to the states of a system does not have any analog in classical mechanics. A similar idea appears in the description of wave phenomena but it is difficult to understand it in terms of physical states. This is where the intuition we have about the physical world starts failing us. For instance, if one interprets $\vert \psi_1 \rangle $ to mean that the system is located at site $1$ and $\vert \psi_2 \rangle$ that the system is at site $2$, then the superposition state $\vert \psi \rangle = \alpha_1 \lvert \psi_1 \rangle + \alpha_2 \lvert \psi_2 \rangle $ would mean that the system is located at two different sites at the same time. The role of the observation, or measurement, comes here into play to save us from the weirdness of the quantum world. Even though the system might be described as being in a superposition of several states, the system's properties won't be an average between the properties of the different states of the superposition. Whenever a measurement is performed, the system "chooses" one of the possible states in a non-deterministic way. Which means that when the system is measured, it will be found to be in a particular state of the superposition with a certain probability. This probability is given by the modulo square of the complex coefficient of that state. Therefore, when the system is actually measured, it will be found with probability $\lvert \alpha_1 \rvert^2$ at site $1$ and with probability $\lvert \alpha_2 \rvert^2$ at site $2$. To summarize, at any given time a system may be in a superposition of several different states. However, when a measurement is done on the system, the system will show the properties of one of the states of the superposition with a certain probability.

A consequence of the superposition principle, is that the wave function, $\vert \psi \rangle$, must be the solution of a linear equation. Thus, the equations describing the connection between the state of the system at one time and the state of the system at another time must be linear equations.

# 3.3 The operators

To each physical quantity $F$ corresponds an operator $\hat{F}$. Operators specify a mathematical prescription that is carried out on the state. In the matrix representation, we say that operators "act" on states, meaning that we have to multiply the vector which represent the state by the matrix which represents the operator. The outcome is a new vector, which represents a new state of the system. The operation of finding the value of a quantity $F$ corresponding to a certain operator $\hat{F}$ for the system in a state $\vert \psi \rangle$ is denoted by the symbol $\langle F \rangle $ which is a short-hand notation for

\begin{equation}
 \langle \hat{F} \rangle \equiv \langle \psi \vert  \hat{F} \vert \psi  \rangle .
\end{equation}

To obtain the expected value of the property $F$ for the system in the state $\lvert \psi \rangle$, one needs to decompose the state $\lvert \psi \rangle$ in terms of the eigenstates $\lvert \phi_i \rangle$ of the operator $\hat{F}$. Those are the vectors that satisfy:

\begin{equation}
\hat{F} \lvert \phi_i \rangle = F_i \lvert \phi_i \rangle .
\end{equation}

The decomposition of $\lvert \psi \rangle$ in terms of the $\lvert \phi_i \rangle$ then reads

\begin{equation}
\lvert \psi \rangle = \sum_i a_i \lvert \phi_i \rangle,
\end{equation}

where $a_i = \langle \phi_i \vert \psi \rangle$. Once we have found $\lvert \psi \rangle = \sum_i a_i \lvert \phi_i \rangle$, we can then find the expected value of $\hat{F}$ for the system as

\begin{equation}
 \langle \hat{F} \rangle = \sum_i \vert a_i \vert^2 F_i .
\end{equation}

Operators must have the following properties:

 The principle of superposition must be satisfied
        		$\hat{F} \alpha_1 \lvert \psi_1 \rangle + \alpha_2 \lvert \psi_2 \rangle = \alpha_1 \hat{F} \lvert \psi_1 \rangle + \alpha_2 \hat{F} \lvert \psi_2 \rangle $.

		Thus, we require the operators to be linear.

 The value of quantities represented by operators must be real

        		$ \langle \hat{F} \rangle = \langle \hat{F} \rangle^{ *}$,

		which means

		$ \langle \psi \vert  \hat{F} \vert \psi  \rangle  = \left( \langle \psi \vert  \hat{F} \vert \psi  \rangle \right)^{*} = \langle \psi \vert  \hat{F}^{\dagger} \vert \psi  \rangle  $,

		Therefore,

		$  \hat{F} = \hat{F}^{\dagger} $.

		Operators are Hermitian.

## 3.4 The time evolution

The evolution in time of the state $\vert \psi\left( \vec{r} , t \right)\rangle$ of the system is determined by its Hamiltonian operator. One way to mathematically describe the time evolution of the state of the system is with the Schrodinger equation

\begin{equation}
 -i \hbar \frac{\partial\vert \psi\left( \vec{r} , t \right)\rangle}{\partial t} = \hat{H} \vert \psi  \rangle .
\end{equation}

The equation above can be solved by specifying the Hamiltonian $\hat{H}$ operator of the system and the initial state of the system $\vert \psi\left( \vec{r} , t = 0 \right)\rangle$. The Hamiltonian operator is the operator associated with the total energy of the system i.e. it is given by the sum of its potential and kinetic energy.

The Schrodinger equation is one of the most important equations of modern physics. It provides an accurate description of all quantum systems which move at non-relativistic speed. In particular, a quantum computer is built by using the Schrodinger equation to understand the behavior of each of its quantum components.

However, in quantum computing the picture is simpler and there is no need to use the Schrodinger equation to find the time-evolution of the system. The same happens in a classical computer, where Maxwell's equations are not needed to describe it but the rules of Boolean's logic for the electrical circuits suffice. So, in a quantum computation time evolves in steps where at each step some quantum gate is applied to the qubits. Therefore, the state of the qubits at some time $t$, after $k$ steps of the computation, is completely specified by the quantum circuit which is used in the computation.

We can reconcile the two descriptions by introducing the "time-evolution operator" $\hat{U}$, which is defined from his action on the state of the system at $t=0$:

\begin{equation}
\lvert \psi \left( \vec{r} , t \right)\rangle = \hat{U} \vert \psi\left( \vec{r} , t=0 \right)\rangle
\end{equation}

In general, $\hat{U}$ can be obtained by integrating the Schrodinger equation. For the case of a time-independent Hamiltonian one finds $\hat{U}=e^{i\hat{H}t/\hbar}$

In quantum computing, $\hat{U}$ will be the product of all successive unitary operations encoded in the quantum gates of a circuit. Therefore, the time-evolution operator at time $t_k$, after $k$ steps of the computation, is $\hat{U}(t=t_k) \equiv \hat{U}_k $ and the state of the system evolves as

\begin{equation}
\lvert \psi\left( t_k \right)\rangle = \hat{U}_k \vert \psi \left(  t_0 \right)\rangle
\end{equation}

#### Example

Consider the following quantum circuit

$$\text{1. Quantum circuit.}$$

Let's calculate the time-evolution operators at each time step in order to give the evolution of the state in time
$$\hat{U}_1 = \hat{I} $$
$$\hat{U}_2 = \hat{X} \hat{I} $$
$$\hat{U}_3 = \hat{X} \hat{X} \hat{I}  $$

where $\hat{X}$ is the quantum NOT gate which flips the state of a qubit $ \lvert 0 \rangle \rightarrow  \lvert 1 \rangle$, $ \lvert 1 \rangle \rightarrow  \lvert 0 \rangle$
We initialize the system to the $\lvert 0 \rangle$ at the beginning of the computation, thus at time $t_0$ we have
$$\lvert \psi \left( t_0 \right)\rangle = \lvert 0 \rangle$$

At following times we find
$$\lvert \psi \left( t_1 \right)\rangle = \hat{U}_1 \lvert 0 \rangle = \hat{I} \lvert 0 \rangle = \lvert 0 \rangle $$
$$\lvert \psi \left( t_2 \right)\rangle = \hat{U}_2 \lvert 0 \rangle = \hat{X} \hat{I} \lvert 0 \rangle = \hat{X} \lvert 0 \rangle = \lvert 1 \rangle  $$
$$\lvert \psi \left( t_3 \right)\rangle = \hat{U}_3 \lvert 0 \rangle = \hat{X} \hat{X} \hat{I} \lvert 0 \rangle = \hat{X} \hat{X} \lvert 0 \rangle = \hat{X} \lvert 1 \rangle = \lvert 0 \rangle $$

## Problems

Given the following wavefunctions describing the state of a system

$ \lvert \psi \left( \vec{r} , t \right)\rangle = \frac{1}{\sqrt{2}} e^{i3t}  \lvert \psi \rangle_1 + \frac{1}{\sqrt{2}} e^{i3t} \lvert \psi \rangle_2   $

$ \lvert \psi \left( \vec{r} , t \right)\rangle = \frac{1}{\sqrt{8}} e^{i5t}  \lvert \psi \rangle_1 + \frac{\sqrt{5}}{\sqrt{8}} e^{i5t} \lvert \psi \rangle_2 + \frac{\sqrt{2}}{\sqrt{8}} e^{i2t} \lvert \psi \rangle_2   $

$ \lvert \psi \left( \vec{r} , t \right)\rangle = \frac{1}{\sqrt{3}} e^{i2t}  \lvert \psi \rangle_1 + \frac{\sqrt{2}}{\sqrt{3}} e^{i7t} \lvert \psi \rangle_2   $

What is the probability that the system is in the state $\lvert \psi \rangle_1$? and the probability of $\lvert \psi \rangle_2$?

Find the expectaion value of the operator $\hat{X}$ for a qubit in the following states:

$ \lvert \psi \rangle = \lvert 0 \rangle$

$ \lvert \psi \rangle = \lvert 1 \rangle$

$ \lvert \psi \rangle = \frac{1}{\sqrt{2}} \left( \lvert 0 \rangle + \lvert 1 \rangle \right)$

Given a system in the initial state $\lvert \psi \left( t_0 \right)\rangle = \lvert 0 \rangle$. Find the state of the system at following times, given the time evolution operators

$ \hat{U}_1 = \hat{X}$

$ \hat{U}_2 = \hat{X}$

$ \hat{U}_3 = \hat{X}$

## References

[1] P. A. M. Dirac, The Principles of Quantum Mechanics (1947 Clarendon Press, Oxford).

[2] J. von Neumann, Mathematische Grundlagen der Quanten-Mechanik, Springer-Verlag, Berlin, 1932.


```python
DIVIDER = 20201227

def transform(number, loops):
    value = 1

    for _ in range(loops):
        value *= number
        value = value % DIVIDER

    return value
```

```python
assert transform(7, 8) == 5764801
assert transform(7, 11) == 17807724
assert transform(17807724, 8) == transform(5764801, 11) == 14897079
```

```python

```

```python
# https://twitter.com/YassineAlouini/status/1342366307753340929
from sympy.ntheory import discrete_log
```

```python
assert discrete_log(DIVIDER, 17807724, 7) == 11
assert discrete_log(DIVIDER, 5764801, 7) == 8
```

```python
def solve(a, b):
    loops = discrete_log(DIVIDER, a, 7)
    enc_key = transform(b, loops)
    return enc_key
```

```python
assert solve(5764801, 17807724) == 14897079
```

```python
a = 8421034
b = 15993936

solve(a, b)
```

    9177528

```python

```


# Understand Collaborative Filtering From Scratch

## Introduction to different types of Collaborative Filtering

### User-User Approach
Also known as "Neighborhood-based approach", "User-User Collaborative Filtering", it calculates the similarity between users and use it as the weight to predict the target item.

In this example, we want to know whether if we should recommend Fargo to Ken or not. Using User-User approach, we firstly calculate the similiarity between each user to Ken. Typically, similiarity can be calculated using pearson correlation. To make a prediction, we take the Fargo's rating from each User as value(- as 0, + as 1) and calculate the weighted sum of it using the similarity between each user. The Denominator is not "n" since it's the weighted sum. Instead of divided equally by 4, the similar the user is, the more weight it should be given.

\begin{align}
\ p(X_i) = \frac{\sum_{i=1}^n Y_i * r(X, Y)}{\sum_{i=1}^n |r(X, Y)|}, \text{where n=4}.
\end{align}

$X$: target user

$Y$: other users

$n$: number of users

$Y_1$: Amy's rating on Fargo, which is -

r(X, Y): when i = 1, it represents the pearson correlation between Ken($X$) and Amy($Y$). X, Y are both a vector of length 4.

$p(X_i)$: the final predicted value for whether if we should recommend Fargo to Ken or not.

### Item-Item Approach
This approach is simply an inversion of the neighborhood-based approach. The intuition is that instead of recommending using the similarity between users, we recommend it based on the similarity between items.

Based on the same example above, to predict whether if we should recommend Fargo to Ken, we calculate the similarity between each items. The rating for Fargo and Pulp Fiction are the same, which means that people tend to have similar rating for these two items, so we may predict Ken will also like Fargo. In the form of equations:

\begin{align}
\ p(X_i) = \frac{\sum_{i=1}^n Y_i * r(X, Y)}{\sum_{i=1}^n |r(X, Y)|}, \text{where n=4}.
\end{align}

$X$: target item

$Y$: other items

$n$: number of items

$Y_1$: The piano's rating by Ken, which is +.

r(X, Y): when i = 1, it represents the pearson correlation between Fargo($X$) and Piano($Y$). X, Y are both a vector of length 4.

$p(X_i)$: the final predicted value for whether if we should recommend Fargo to Ken or not.

### Classification Approach

Collaborative filtering can also be formulated as a classification problem. Once the user ratings are converted into
the following screenshot's format, almost any supervised learning method can be used to classify unseen items. Pazzani and Billsus, some recommender system researchers, first reduce the dimensionality of the vectors by applying singular value decomposition and then use a neural network as a learning method.

### Note
* 'memory based', 'model based' and 'hybrid' are different types of Collaborative Filtering.

## Using MovieLens Dataset to implement Collaborative Filtering

The data can be downloaded [here](https://grouplens.org/datasets/movielens/100k/).

According to GroupLens, MovieLens data sets were collected by the GroupLens Research Project
at the University of Minnesota.

This data set consists of:
	* 100,000 ratings (1-5) from 943 users on 1682 movies
	* Each user has rated at least 20 movies
    * Simple demographic info for the users (age, gender, occupation, zip)
    * Genre information of movies

```python
import numpy as np
import pandas as pd
```

```python
cd ml-100k/
```

    /Users/johnnychiu/Desktop/MyFiles/learning/Machine-Learning/RecommenderSystem/ml-100k

### Read files

```python
names = ['user_id', 'item_id', 'rating', 'timestamp']
df = pd.read_csv('u.data', sep='\t', names=names)
df.head()
```

      user_id
      item_id
      rating
      timestamp

      0
      196
      242
      3
      881250949

      1
      186
      302
      3
      891717742

      2
      22
      377
      1
      878887116

      3
      244
      51
      2
      880606923

      4
      166
      346
      1
      886397596

```python
n_users = df.user_id.unique().shape[0]
n_items = df.item_id.unique().shape[0]
print(str(n_users) + ' users')
print(str(n_items) + ' items')
```

    943 users
    1682 items

We want to get the rating matrix which each row represent each user and the value of each column represent the rating this particular user gave.

```python
ratings = np.zeros((n_users, n_items))
ratings.shape
```

    (943, 1682)

```python
for row in df.itertuples():
    ratings[row[1]-1, row[2]-1] = row[3]
ratings
```

    array([[ 5.,  3.,  4., ...,  0.,  0.,  0.],
           [ 4.,  0.,  0., ...,  0.,  0.,  0.],
           [ 0.,  0.,  0., ...,  0.,  0.,  0.],
           ...,
           [ 5.,  0.,  0., ...,  0.,  0.,  0.],
           [ 0.,  0.,  0., ...,  0.,  0.,  0.],
           [ 0.,  5.,  0., ...,  0.,  0.,  0.]])

In supervised learning, separate data into training and testing dataset, we usually do random sampling to rows of the dataframe. The reason is that we want to fix a function to the training dataset and use the function to predict the outcome of the testing dataset.

However, for recommender systems with collaborative filtering (no features), this just won't work anymore, because all of the items/users need to be available when the model is first built. We do need the rating data for each user to calculate their recommendation items, in this case, movies. Since we have already known that each users has rated at least 20 movies. We then randomly take 10 rating out of these 20+ movies and assign them to test dataset.

(I am not sure how to calculate the mse using the pred of train and test yet. It's not clear since pred of train should have more rating than test. How can they compare with each other? )

We will only use the rated movies in the test dataset to calculate the mean square error(MSE). That is, for train dataset, we will keep only the index of non-zero value from test dataset, and use them to calculate the MSE with test dataset.

### Generating Train and Test datasets

```python
def train_test_split(ratings):
    test = np.zeros(ratings.shape)
    train = ratings.copy()
    for user in xrange(ratings.shape[0]):
        test_ratings = np.random.choice(ratings[user, :].nonzero()[0],
                                        size=10,
                                        replace=False)
        train[user, test_ratings] = 0.
        test[user, test_ratings] = ratings[user, test_ratings]

    # Test and training are truly disjoint
    assert(np.all((train * test) == 0))
    return train, test
```

```python
train, test = train_test_split(ratings)
print '#Train'
print train.shape
print train
print '\n'

print '#Test'
print test.shape
print test
```

    #Train
    (943, 1682)
    [[ 5.  3.  4. ...,  0.  0.  0.]
     [ 4.  0.  0. ...,  0.  0.  0.]
     [ 0.  0.  0. ...,  0.  0.  0.]
     ...,
     [ 0.  0.  0. ...,  0.  0.  0.]
     [ 0.  0.  0. ...,  0.  0.  0.]
     [ 0.  5.  0. ...,  0.  0.  0.]]

    #Test
    (943, 1682)
    [[ 0.  0.  0. ...,  0.  0.  0.]
     [ 0.  0.  0. ...,  0.  0.  0.]
     [ 0.  0.  0. ...,  0.  0.  0.]
     ...,
     [ 5.  0.  0. ...,  0.  0.  0.]
     [ 0.  0.  0. ...,  0.  0.  0.]
     [ 0.  0.  0. ...,  0.  0.  0.]]
    [ 0.  0.  0. ...,  0.  0.  0.]

The following function calculates the cosine similarity, ie pearson correlation between each user using the input dataset.

### Build a recommender system using collaborative filtering
1. calcuate similarity
2. predict the probability using User-User and Item-Item approach
3. validating the result using MSE

```python
def fast_similarity(ratings, kind='user', epsilon=1e-9):
    # epsilon -> small number for handling dived-by-zero errors
    if kind == 'user':
        sim = ratings.dot(ratings.T) + epsilon
    elif kind == 'item':
        sim = ratings.T.dot(ratings) + epsilon
    norms = np.array([np.sqrt(np.diagonal(sim))])
    return (sim / norms / norms.T)
```

```python
user_similarity = fast_similarity(train, kind='user')
item_similarity = fast_similarity(train, kind='item')
```

```python
print user_similarity.shape
user_similarity
```

    (943, 943)

    array([[ 1.        ,  0.14723095,  0.04579818, ...,  0.11663262,
             0.18089804,  0.36751597],
           [ 0.14723095,  1.        ,  0.07608248, ...,  0.11787781,
             0.19125926,  0.0819063 ],
           [ 0.04579818,  0.07608248,  1.        , ...,  0.143708  ,
             0.12051638,  0.02956977],
           ...,
           [ 0.11663262,  0.11787781,  0.143708  , ...,  1.        ,
             0.1062942 ,  0.05239333],
           [ 0.18089804,  0.19125926,  0.12051638, ...,  0.1062942 ,
             1.        ,  0.17675665],
           [ 0.36751597,  0.0819063 ,  0.02956977, ...,  0.05239333,
             0.17675665,  1.        ]])

```python
def predict_fast_simple(ratings, similarity, kind='user'):
    if kind == 'user':
        return similarity.dot(ratings) / np.array([np.abs(similarity).sum(axis=1)]).T
    elif kind == 'item':
        return ratings.dot(similarity) / np.array([np.abs(similarity).sum(axis=1)])
```

```python
item_prediction = predict_fast_simple(train, item_similarity, kind='item')
user_prediction = predict_fast_simple(train, user_similarity, kind='user')
```

user_prediction, item_prediction records the predict recommendation probability for each movie.

```python
print user_prediction.shape
user_prediction
```

    (943, 1682)

    array([[  2.24055006e+00,   7.74126048e-01,   4.59618296e-01, ...,
              5.47242008e-04,   6.91019414e-03,   7.74567790e-03],
           [  1.74732903e+00,   3.41433616e-01,   3.13708503e-01, ...,
              3.45427384e-03,   3.08290521e-03,   2.01289791e-03],
           [  1.29736821e+00,   3.30136379e-01,   2.61173855e-01, ...,
              9.50596148e-03,   3.16705197e-03,   1.63376661e-03],
           ...,
           [  1.80659731e+00,   3.93393694e-01,   3.28852773e-01, ...,
              2.41578076e-03,   4.24774455e-03,   1.12324913e-03],
           [  1.88930554e+00,   5.99816782e-01,   3.19051195e-01, ...,
              2.47856144e-03,   5.43287722e-03,   3.59721904e-03],
           [  2.30551510e+00,   9.09929162e-01,   4.93472907e-01, ...,
              7.86337360e-15,   7.94449896e-03,   7.59479840e-03]])

```python
from sklearn.metrics import mean_squared_error

def get_mse(pred, actual):
    # Ignore nonzero terms.
    pred = pred[actual.nonzero()].flatten()
    actual = actual[actual.nonzero()].flatten()
    return mean_squared_error(pred, actual)
```

```python
item_prediction = predict_fast_simple(train, item_similarity, kind='item')
user_prediction = predict_fast_simple(train, user_similarity, kind='user')

print 'User-based CF MSE: ' + str(get_mse(user_prediction, test))
print 'Item-based CF MSE: ' + str(get_mse(item_prediction, test))
```

    User-based CF MSE: 8.4931401358
    Item-based CF MSE: 11.6132805105

In this case, User-User Approach is a better way for recommendation.

----

### Build a recommender system using Top $K$ collaborative filtering
We can attempt to improve our prediction MSE by only considering the top $k$ users who are most similar to the input user (or, similarly, the top $k$ items). That is, when we calculate the sums over $Y_i$, we only sum over the top $k$ most similar users.

\begin{align}
\ p(X_i) = \frac{\sum_{i=1}^n Y_i * r(X, Y)}{\sum_{i=1}^n |r(X, Y)|}, \text{where n=k, instead of the total number of other users}.
\end{align}

1. calcuate similarity, which is the same as the previous one.
2. predict the probability using the top $k$ User-User and Item-Item approach
3. validating the result using MSE

```python
def predict_topk(ratings, similarity, kind='user', k=40):
    pred = np.zeros(ratings.shape)
    if kind == 'user':
        for i in xrange(ratings.shape[0]):
            top_k_users = [np.argsort(similarity[:,i])[:-k-1:-1]]
            for j in xrange(ratings.shape[1]):
                pred[i, j] = similarity[i, :][top_k_users].dot(ratings[:, j][top_k_users])
                pred[i, j] /= np.sum(np.abs(similarity[i, :][top_k_users]))
    if kind == 'item':
        for j in xrange(ratings.shape[1]):
            top_k_items = [np.argsort(similarity[:,j])[:-k-1:-1]]
            for i in xrange(ratings.shape[0]):
                pred[i, j] = similarity[j, :][top_k_items].dot(ratings[i, :][top_k_items].T)
                pred[i, j] /= np.sum(np.abs(similarity[j, :][top_k_items]))

    return pred
```

```python
pred = predict_topk(train, user_similarity, kind='user', k=40)
print 'Top-k User-based CF MSE: ' + str(get_mse(pred, test))

pred = predict_topk(train, item_similarity, kind='item', k=40)
print 'Top-k Item-based CF MSE: ' + str(get_mse(pred, test))
```

    Top-k User-based CF MSE: 6.51275063533
    Top-k Item-based CF MSE: 7.74005106481

----

### Build a recommender system using Bias-subtracted Collaborative Filtering
Some user may tend to give lower ratings while some may tend to give generally higher ratings. It will affect the recommend probability since the user that has higher rating will affect the probabilty more than those who generally rates lower.

Let us try subtracting each user's average rating when summing over similar user's ratings and then add that average back in at the end. We modify our equation to become:

\begin{align}
\ p(X_i) = \bar{Y} + \frac{\sum_{i=1}^n (Y_i- \bar{Y}) * r(X, Y)}{\sum_{i=1}^n |r(X, Y)|}, \text{where n=4}.
\end{align}

where $\bar{Y}$ is user $Y$'s average rating.

```python
def predict_nobias(ratings, similarity, kind='user'):
    if kind == 'user':
        user_bias = ratings.mean(axis=1)
        ratings = (ratings - user_bias[:, np.newaxis]).copy()
        pred = similarity.dot(ratings) / np.array([np.abs(similarity).sum(axis=1)]).T
        pred += user_bias[:, np.newaxis]
    elif kind == 'item':
        item_bias = ratings.mean(axis=0)
        ratings = (ratings - item_bias[np.newaxis, :]).copy()
        pred = ratings.dot(similarity) / np.array([np.abs(similarity).sum(axis=1)])
        pred += item_bias[np.newaxis, :]

    return pred
```

```python
user_pred = predict_nobias(train, user_similarity, kind='user')
print 'Bias-subtracted User-based CF MSE: ' + str(get_mse(user_pred, test))

item_pred = predict_nobias(train, item_similarity, kind='item')
print 'Bias-subtracted Item-based CF MSE: ' + str(get_mse(item_pred, test))
```

    Bias-subtracted User-based CF MSE: 8.7710448431
    Bias-subtracted Item-based CF MSE: 9.79353560567

------

### Validation the Recommender System
Let's look at our item similarity matrix and see if similar items "make sense". We input a selected movie, and check if the top 5 recommend movies are "good". There is a webiste called [themoviedb.org](themoviedb.org) which has a free API. If we have the IMDB "movie id" for a movie, then we can use this API to return the posters of movies.

(The movie_url doesn't work. I will skip this part) In u.item data, we have the movie_url for each movie. We can somehow use the movie_url to get the movie_id for each movie and send it as a parameter to themobedb.org's API to get the posters of movies.

```python
!head -5 u.item
```

    1|Toy Story (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?Toy%20Story%20(1995)|0|0|0|1|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0
    2|GoldenEye (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?GoldenEye%20(1995)|0|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0|1|0|0
    3|Four Rooms (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?Four%20Rooms%20(1995)|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|1|0|0
    4|Get Shorty (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?Get%20Shorty%20(1995)|0|1|0|0|0|1|0|0|1|0|0|0|0|0|0|0|0|0|0
    5|Copycat (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?Copycat%20(1995)|0|0|0|0|0|0|1|0|1|0|0|0|0|0|0|0|1|0|0

```python
# Load in movie data
idx_to_movie = {}
with open('u.item', 'r') as f:
    for line in f.readlines():
        info = line.split('|')
        idx_to_movie[int(info[0])-1] = info[1]

def top_k_movies(similarity, mapper, movie_idx, k=6):
    return [mapper[x] for x in np.argsort(similarity[movie_idx,:])[:-k-1:-1]]
```

```python
idx = 0 # Toy Story
movies = top_k_movies(item_similarity, idx_to_movie, idx)
movies
```

    ['Toy Story (1995)',
     'Star Wars (1977)',
     'Return of the Jedi (1983)',
     'Independence Day (ID4) (1996)',
     'Rock, The (1996)',
     'Fargo (1996)']

```python
idx = 70 # Lion King
movies = top_k_movies(item_similarity, idx_to_movie, idx)
movies
```

    ['Lion King, The (1994)',
     'Aladdin (1992)',
     'Forrest Gump (1994)',
     'Beauty and the Beast (1991)',
     'Jurassic Park (1993)',
     'E.T. the Extra-Terrestrial (1982)']

Reference
* http://recommender-systems.org/collaborative-filtering/
* https://www.analyticsvidhya.com/blog/2016/06/quick-guide-build-recommendation-engine-python/
* http://blog.ethanrosenthal.com/2015/11/02/intro-to-collaborative-filtering/
* http://nbviewer.jupyter.org/github/ethen8181/machine-learning/blob/master/recsys/1_ALSWR.ipynb

Mathematical expressions reference
* http://jupyter-notebook.readthedocs.io/en/latest/examples/Notebook/Typesetting%20Equations.html
* http://csrgxtu.github.io/2015/03/20/Writing-Mathematic-Fomulars-in-Markdown/


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

# Efectos del lazo cerrado

## Objetivos
- Determinar la estabilidad de sistemas de lazo abierto y lazo cerrado.
- Verificar el efecto de cerrar un lazo de control sobre sistemas de  tiempo continuo.

## Lazo cerrado

Tomando como base el bucle típico de control de la siguiente figura.

Puede deducirse que la función de transferencia desde la **referencia** $R(s)$ hasta la **controlada** $C(s)$.

Observe que el error se define como:
\begin{equation}
E(s) = R(s) - H(s) C(s)
\end{equation}
La señal **controlada** corresponde a la transformación que realiza el **controlador**, el **actuador** y la **planta** sobre el **error**.
\begin{align}
\color{blue}{C(s)} &= \left (G_c(s)G_a(s)G_p(s)  \right )  E(s) \\
\color{blue}{C(s)} &= \left (G_c(s)G_a(s)G_p(s) \right ) \left ( \color{blue}{\color{blue}{R(s)}} - H(s) \color{blue}{C(s)} \right ) \\
\color{blue}{C(s)} &= \left (G_c(s)G_a(s)G_p(s) \right ) \color{blue}{\color{blue}{R(s)}} - \left (G_c(s)G_a(s)G_p(s)H(s) \right )\color{blue}{C(s)} \\
\end{align}
\begin{equation}
\color{blue}{C(s)} +  \left (G_c(s)G_a(s)G_p(s)H(s) \right )\color{blue}{C(s)}  =  \left (G_c(s)G_a(s)G_p(s) \right ) \color{blue}{R(s)}
\end{equation}
\begin{equation}
\color{blue}{C(s)} \left ( 1 +  G_c(s)G_a(s)G_p(s)H(s)\right ) =   \left (G_c(s)G_a(s)G_p(s) \right ) \color{blue}{R(s)}
\end{equation}

Así, la función de transferencia de lazo cerrado es:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{G_c(s)G_a(s)G_p(s)}{1 + G_c(s)G_a(s)G_p(s)H(s)}
\end{equation}

Para efectos prácticos se reune en un solo modelo a los sistemas **Actuador** y **Planta**, pues estos dos sistemas conforman el **Proceso** que se debe controlar. Así, la función de transferencia de lazo cerrado será:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{G_c(s)G_p(s)}{1 + G_c(s)G_p(s)H(s)}
\end{equation}

Tenga en cuenta que $G_p(s)$ incorpora las dinámicas de **Actuador** y **Planta**.

El rol del **Sensor** requiere respuestas "rápidas" y "precisas".

El sistema **Controlador** debe ser diseñado para lograr comportamientos deseados en el sistema en lazo cerrado, es decir, se moldea la forma de $C(s)$ a partir de $R(s)$ ajustando $G_c(s)$ de forma apropiada.

## Lazo abierto vs Lazo cerrado

Las características más relevantes de la respuesta transitoria de un sistema está relacionada con la ubicación de sus polos.

De las ecuaciones anteriores, puede observarse que los polos se reubican en lazo cerrado de acuerdo con el controlador. Para este analisis considere los modelos lineales definidos como divisiones de polinomios.

\begin{equation}
\frac{C(s)}{R(s)} = \frac{G_c(s)G_p(s)}{1 + G_c(s)G_p(s)H(s)}
\end{equation}

\begin{equation}
\frac{C(s)}{R(s)} = \frac{\frac{N_c(s)}{D_c(s)}\frac{N_p(s)}{D_p(s)}}{1 + \frac{N_c(s)}{D_c(s)}\frac{N_p(s)}{D_p(s)}H(s)}
\end{equation}

Considere que el sensor es perfecto, es decir, $H(s)=1$.

La función de transferencia de lazo cerrado es:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{\frac{N_c(s)N_p(s)}{D_c(s)D_p(s)}}{\frac{D_c(s)D_p(s) + N_c(s)N_p(s)}{D_c(s)D_p(s)}} = \frac{N_c(s)N_p(s)}{D_c(s)D_p(s) + N_c(s)N_p(s)}
\end{equation}

Mientras la función de transferencia de lazo abierto es:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{N_c(s)N_p(s)}{D_c(s)D_p(s)}
\end{equation}

Observe que los numeradores se mantienen, esto indica que los ceros del sistema en lazo cerrado son los mismos que en lazo abierto.

Observe que los denominadores cambian, esto indica que los polos del sistema en lazo cerrado cambian respecto al lazo abierto.

**Ejemplo**

Suponga un proceso modelado por:

$$G_p(s) = \frac{2}{4s - 3}$$
$$G_p(s) = \frac{-2/3}{\frac{-4}{3}s + \frac{3}{3}}$$
$$G_p(s) = \frac{-2/3}{\frac{-4}{3}s + 1}$$

y una estrategia de contro definida por:

$$G_c(s) = k_c$$

- ¿El sistema $G_p$ es estable?
- ¿Qué efecto tiene realimentar el sistema con el controlador definido?

En análisis se realizará a partir de las raíces del sistema teniendo en cuenta que la función de transferencia de lazo cerrado es:

\begin{equation}
\frac{C(s)}{R(s)} =  \frac{N_c(s)N_p(s)}{D_c(s)D_p(s) + N_c(s)N_p(s)}
\end{equation}

$$G_{LC}(s) = \frac{2k_c}{4s - 3 + 2k_c}$$

```python
# Se define la función de transferencia del proceso
Gp = control.tf(2, [4,-3])
Gp
```

$$\frac{2}{4 s - 3}$$

```python
# Se hallan los polos del proceso
polos = Gp.pole()
polos
```

    array([0.75])

```python
# Se hallan los ceros del proceso
ceros = Gp.zero()
ceros
```

    array([], dtype=float64)

```python
# Se grafica el mapa de polos y ceros
control.pzmap(Gp)
plt.grid(True)
```

- El sistema no tiene ceros.
- El sistema tiene un polo en $s = 0.75$.
- La respuesta dinámica del sistema está dominada por $e^{0.75t}$.
- Este sistema es inestable.

```python
# Se grafica la respuesta al escalón
ts = np.linspace(0, 20, 1000)
_, y = control.step_response(Gp, ts)
plt.plot(ts, y)
plt.grid(True)
```

Se tomarán distintos escenarios para $G_c(s) = k_c$.

```python
Gc1 = 0.01
Gc2 = 0.1
Gc3 = 1.5
Gc4 = 3
Gc5 = 5
Gc6 = 10
Gc7 = 1.501
```

```python
# Caso 1
G_LC1 = control.feedback(Gc1*Gp,1)
_, y1 = control.step_response(G_LC1, ts)

G_LC1
```

$$\frac{0.02}{4 s - 2.98}$$

```python
# Caso 2
G_LC2 = control.feedback(Gc2*Gp,1)
_, y2 = control.step_response(G_LC2, ts)
G_LC2
```

$$\frac{0.2}{4 s - 2.8}$$

```python
# Caso 3
G_LC3 = control.feedback(Gc3*Gp,1)
_, y3 = control.step_response(G_LC3, ts)
G_LC3
```

$$\frac{3}{4 s}$$

```python
# Caso 4
G_LC4 = control.feedback(Gc4*Gp,1)
_, y4 = control.step_response(G_LC4, ts)
G_LC4
```

$$\frac{6}{4 s + 3}$$

```python
# Caso 5
G_LC5 = control.feedback(Gc5*Gp,1)
_, y5 = control.step_response(G_LC5, ts)
G_LC5
```

$$\frac{10}{4 s + 7}$$

```python
# Caso 6
G_LC6 = control.feedback(Gc6*Gp,1)
_, y6 = control.step_response(G_LC6, ts)
G_LC6
```

$$\frac{20}{4 s + 17}$$

```python
# Caso 7
G_LC7 = control.feedback(Gc7*Gp,1)
_, y7 = control.step_response(G_LC7, ts)
G_LC7
```

$$\frac{3.002}{4 s + 0.002}$$

```python
# Se grafica el mapa de polos y ceros para todos los escenarios
control.pzmap(Gp)
control.pzmap(G_LC1)
control.pzmap(G_LC2)
control.pzmap(G_LC3)
control.pzmap(G_LC4)
control.pzmap(G_LC5)
control.pzmap(G_LC6)
control.pzmap(G_LC7)
plt.grid(True)
```

```python
# Se grafica la respuesta al escalón para todos los escenarios
plt.plot(ts, y,ts, y1,ts, y2,ts, y3,ts, y4,ts, y5,ts, y6)
plt.legend(['Proceso',
            'k=' + str(Gc1),
            'k=' + str(Gc2),
            'k=' + str(Gc3),
            'k=' + str(Gc4),
            'k=' + str(Gc5),
            'k=' + str(Gc6),
            'k=' + str(Gc7)
           ])
plt.grid(True)
```

```python
# Se grafica la respuesta al escalón para los escenarios inestables
plt.plot(ts, y,ts, y1,ts, y2,ts, y3)
plt.legend(['Proceso',
            'k=' + str(Gc1),
            'k=' + str(Gc2),
            'k=' + str(Gc3)])
plt.grid(True)
```

```python
# La respuesta al escalón para los escenarios estables y el integrador
plt.plot(ts, y3,ts, y4,ts, y5,ts,y6,ts,y7)
plt.legend(['k=' + str(Gc3),
            'k=' + str(Gc4),
            'k=' + str(Gc5),
            'k=' + str(Gc6),
           'k=' + str(Gc7)])
plt.grid(True)
```

**Observaciones**

- El proceso es de primer orden inestable.
- El controlador de modelo $G_c(s) = k_c$ fue capaz de estabilizar al sistema a partir de un valor determinado de $k_c$
- Una vez estable, el efecto de aumentar $k_c$ se aprecia en la reducción del tiempo de estabilización.

**Preguntas**
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal controlada?
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal de error?

------------------------

1. Repita el análisis para un proceso modelado por

$$ G_p(s) = \frac{0.75}{s^2 + 4s + 2}$$

y una estrategia de contro definida por:

$$G_c(s) = k_c$$

- ¿El sistema $G_p$ es estable?
- ¿Qué efecto tiene realimentar el sistema con el controlador definido?
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal controlada?
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal de error?

2. Repita el análisis para un proceso modelado por

$$ G_p(s) = \frac{2}{s^3 + 2s^2 + 4s + 2}$$

y una estrategia de contro definida por:

$$G_c(s) = k_c$$

- ¿El sistema $G_p$ es estable?
- ¿Qué efecto tiene realimentar el sistema con el controlador definido?
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal controlada?
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal de error?

```python

```


# Shape interpolation

Load sympy

```
from sympy import *
from __future__ import division, print_function, with_statement

init_printing()
```

Initialize variables

```
p0 = Matrix(symbols('p0[0:3]', real=True))
p1 = Matrix(symbols('p1[0:3]', real=True))
p2 = Matrix(symbols('p2[0:3]', real=True))
p3 = Matrix(symbols('p3[0:3]', real=True))

q0 = Matrix(symbols('q0[0:3]', real=True))
q1 = Matrix(symbols('q1[0:3]', real=True))
q2 = Matrix(symbols('q2[0:3]', real=True))
q3 = Matrix(symbols('q3[0:3]', real=True))

u = Matrix(((p0 - p3).T, (p1 - p3).T, (p2 - p3).T)).T
v = Matrix(((q0 - q3).T, (q1 - q3).T, (q2 - q3).T)).T

uInv = Matrix((symbols('uInv[0][(0:3)]', real=True),
               symbols('uInv[1][(0:3)]', real=True),
               symbols('uInv[2][(0:3)]', real=True)))

m = Matrix((symbols('m[0][(0:3)]', real=True),
            symbols('m[1][(0:3)]', real=True),
            symbols('m[2][(0:3)]', real=True)))
t = Matrix(symbols('t[0:3]', real=True))

Ai = symbols('Ai', real=True)
alpha = symbols('alpha', real=True)
```

Error expression for 1 triangle

```
Ei = Ai * ((v * uInv - m).norm() ** 2 + alpha * ((q3 - v * uInv * p3) - t).norm() ** 2)
```

```
Ei
```

Compute equations from partial derivatives (this takes a while...)

```
eqs = []
q = [q0, q1, q2, q3]
for qi in q:
#for qi in [q0]:
    for x in (0, 1, 2):
    #for x in (0,):
        qx = []
        coeffs = {}
        for qj in q:
            qx.append(qj[x])
            coeffs[qj[x]] = Integer(0)
        Eid = Ei.diff(qi[x]).expand().collect(qx)
        eqA = Matrix(map(lambda qjx: Eid.coeff(qjx).factor(), qx)).T
        eqX = Matrix(qx)
        eqB = Matrix([-Eid.as_independent(*qx)[0].factor()])
        eqs.append((eqA, eqX, eqB))
```

```
len(eqs)
```

Custom C code printer to avoid using pow with integers (took from [here](http://stackoverflow.com/a/33403251/1782792))

```
from sympy.utilities.codegen import CCodePrinter
class MyCCodePrinter(CCodePrinter):
    def _print_Pow(self, expr):
        if expr.exp.is_integer and expr.exp.is_number:
            return '(' + '*'.join([self._print(expr.base)]*expr.exp) + ')'
        else:
            return super(MyCCodePrinter, self)._print_Pow(expr)
```

Format as C code

```
import re
cPrinter = MyCCodePrinter()
varRe = re.compile(r'(\w)\s*(\d+)\s*\[\s*(\d+)\s*\]')
idx2Re = re.compile(r'\[\s*(\d+)\s*\]\s*\[\s*(\d+)\s*\]')
idx1Re = re.compile(r'\[\s*(\d+)\s*\]')
with open('include/surfmorph/surfmorph_codegen.h', 'w') as f:
    f.write('\n')
    f.write('////////////////////////////////////////////////////////////////////////////////\n')
    f.write('// This code has been automatically generated with SymPy ///////////////////////\n')
    f.write('////////////////////////////////////////////////////////////////////////////////\n')
    f.write('\n')
    f.write('#ifndef SURFMORPH_CODEGEN_H\n')
    f.write('#define SURFMORPH_CODEGEN_H\n')
    f.write('\n')
    f.write('namespace surfmorph\n')
    f.write('{\n')
    f.write('\n')
    f.write('template<typename ScalarT,\n')
    f.write('         typename IdxT,\n')
    f.write('         typename PointT,\n')
    f.write('         typename VectorT,\n')
    f.write('         typename MatrixT,\n')
    f.write('         typename CoeffsT,\n')
    f.write('         typename IndepsT>\n')
    f.write('void updateInterpolationSystem\n')
    f.write('(\n')
    # Turns out only q3 is necessary as parameter
    for qi in q[:-1]:
        f.write('    const PointT &,\n')
    f.write('    const PointT &{0},\n'.format(varRe.sub(r'p\2', str(q[-1][0]))))
    for qi in q:
        f.write('    const IdxT {0},\n'.format(varRe.sub(r'p\2Idx', str(qi[0]))))
    f.write('    const MatrixT &{0},\n'.format(str(uInv[0]).split('[')[0]))
    f.write('    const MatrixT &{0},\n'.format(str(m[0]).split('[')[0]))
    f.write('    const VectorT &{0},\n'.format(str(t[0]).split('[')[0]))
    f.write('    const ScalarT {0},\n'.format(str(Ai)))
    f.write('    const ScalarT {0},\n'.format(str(alpha)))
    f.write('    CoeffsT &coeffs,\n')
    f.write('    IndepsT &indeps\n')
    f.write(')\n')
    f.write('{\n')
    f.write('\n')
    f.write('    // Make sure coeff and indeps data is allocated\n')
    f.write('#ifdef SURFMORPH_PARALLELIZE_INTERPOLATION\n')
    f.write('#pragma omp single\n')
    f.write('#endif\n')
    f.write('    {\n')
    qVars = sum(map(list, q), [])
    for i, (qi, eq) in enumerate(zip(qVars, eqs)):
        eqName = varRe.sub(r'3 * p\2Idx + \3', str(qi))
        for qj in eq[1]:
            termName = varRe.sub(r'3 * p\2Idx + \3', str(qj))
            f.write('        coeffs[{{{0}, {1}}}] = coeffs[{{{0}, {1}}}];\n'.format(eqName, termName))
        f.write('        indeps[{0}] = indeps[{0}];\n'.format(eqName))
    f.write('    }\n')
    f.write('\n')
    f.write('#ifdef SURFMORPH_PARALLELIZE_INTERPOLATION\n')
    f.write('#pragma omp sections\n')
    f.write('#endif\n')
    f.write('    {\n\n')
    for i, (qi, eq) in enumerate(zip(qVars, eqs)):
        eqName = varRe.sub(r'3 * p\2Idx + \3', str(qi))
        f.write('#ifdef SURFMORPH_PARALLELIZE_INTERPOLATION\n')
        f.write('#pragma omp section\n')
        f.write('#endif\n')
        f.write('        {\n')
        f.write('            // Equation {0}\n'.format(i + 1))
        for qj, a in zip(eq[1], eq[0]):
            termName = varRe.sub(r'3 * p\2Idx + \3', str(qj))
            eqCode = cPrinter.doprint(a)
            eqCode = idx2Re.sub(r'(\1, \2)', eqCode)
            eqCode = idx1Re.sub(r'(\1)', eqCode)
            f.write('            coeffs[{{{0}, {1}}}] += {2};'.format(eqName, termName, eqCode))
            f.write('\n')
        indepCode = cPrinter.doprint(eq[2][0])
        indepCode = idx2Re.sub(r'(\1, \2)', indepCode)
        indepCode = idx1Re.sub(r'(\1)', indepCode)
        f.write('            indeps[{0}] += {1};'.format(eqName, indepCode))
        f.write('\n')
        f.write('        }\n\n')
    f.write('    }\n')
    f.write('}\n\n')
    f.write('}\n\n')
    f.write('#endif\n\n')
    f.write('////////////////////////////////////////////////////////////////////////////////\n')
```

Save equations in Latex file

```
eqLatex = map(lambda eq: '\\begin{{equation*}}\n{0}^T\n{1}\n=\n{2}\n\\end{{equation*}}\n\\clearpage\n'\
             .format(latex(eq[0].T), latex(eq[1]), latex(eq[2])), eqs)
with open('eqs.tex', 'w') as f:
    f.writelines(eqLatex)
```


```python
from sympy import *
```

```python
x = Symbol('x')
```

```python
init_printing(use_unicode=True)
```

```python
expr = exp(-x) * cos(x)
expr
```

```python
integrate(expr, x)
```

```python
Integral(expr, x)
```

```python
simplify(gamma(x)*gamma(1-x))
```

```python

```


# Random Signals and LTI-Systems

*This jupyter notebook is part of a [collection of notebooks](../index.ipynb) on various topics of Digital Signal Processing. Please direct questions and suggestions to [Sascha.Spors@uni-rostock.de](mailto:Sascha.Spors@uni-rostock.de).*

## Linear Prediction

In general, [predictive modeling](https://en.wikipedia.org/wiki/Predictive_modelling) aims at predicting outcomes using (statistical) models of the underlying processes. When applied to discrete-time signals, it refers to predicting future values from past ones. Different signal models are used for this purpose. Forward [linear prediction](https://en.wikipedia.org/wiki/Linear_prediction) aims at predicting the actual value $x[k]$ of a discrete random signal by a linear combination of its past values. It is frequently applied in lossless and lossy coding of signals. For instance, [linear predictive coding](https://en.wikipedia.org/wiki/Linear_predictive_coding) (LPC) is a major building block of many [speech coding](https://en.wikipedia.org/wiki/Speech_coding) and speech synthesis techniques.

### Theory

It is assumed that $x[k]$ is a real-valued discrete-time random signal. The prediction $\hat{x}[k]$ of the current sample $x[k]$ is computed from a weighted superposition of the past $N$ samples

\begin{equation}
\hat{x}[k] = \sum_{\kappa = 1}^{N} h[\kappa, k] \, x[k - \kappa]
\end{equation}

where $N$ denotes the order of the predictor and $h[\kappa, k]$ the weights of the past $N$ samples $x[k - \kappa]$ at time-instant $k$. The weights might differ for different time-instants. This constitutes a [moving average](https://en.wikipedia.org/wiki/Moving-average_model) (MA) model or a linear filter with finite impulse response (FIR). Therefore the weights $h[\kappa, k]$ can be interpreted as the filter coefficients of a time-variant non-recursive FIR filter.

It is beneficial to express above sum as an inner product of vectors. Combining the $N$ filter coefficients and the samples of the signal into the $N \times 1$ vectors (i.e. column vectors)

\begin{align}
\mathbf{x}[k] &= \left[ x[k], x[k-1], \dots, x[k-N+1] \right]^T \\
\mathbf{h}[k] &= \left[ h[1, k], h[2, k], \dots, h[N, k] \right]^T
\end{align}

yields

\begin{equation}
\hat{x}[k] = \mathbf{h}^T[k] \, \mathbf{x}[k-1]
\end{equation}

for the above sum equation.

The aim of linear prediction is to determine the filter coefficients such that the predicted signal $\hat{x}[k]$ matches the signal $x[k]$ as close as possible. In order to quantify the deviation between the signal $x[k]$ and its predicted value $\hat{x}[k]$ at time-instance $k$, the error signal

\begin{align}
e[k] &= x[k] - \hat{x}[k] \\
&= x[k] - \mathbf{h}^T[k] \, \mathbf{x}[k-1]
\end{align}

is introduced. The error $e[k]$ equals zero, if the signal $x[k]$ is perfectly predicted. In general, this cannot be achieved by a finite predictor order $N$. The problem of determining suitable filter coefficients could be approached by directly minimizing the (average) error. However, this constitutes a non-convex optimization problem. Instead, the quadratic average of the error $e[k]$ is typically used. This measure is known as [*mean squared error*](https://en.wikipedia.org/wiki/Mean_squared_error) (MSE). It is defined as

\begin{align}
J &= E \left\{ | e[k] |^2 \right\} \\
&= E \left\{ | x[k] - \mathbf{h}^T[k] \, \mathbf{x}[k-1] |^2 \right\}
\end{align}

Above equation is referred to as [*cost function*](https://en.wikipedia.org/wiki/Loss_function) $J$ of the optimization problem. We aim at minimizing the cost function, hence minimizing the MSE between the signal $x[k]$ and its prediction $\hat{x}[k]$. The solution of this [convex optimization](https://en.wikipedia.org/wiki/Convex_optimization) problem is referred to as [minimum mean squared error](https://en.wikipedia.org/wiki/Minimum_mean_square_error) (MMSE) solution. Minimizing the cost function is achieved by calculating its gradient with respect to the filter coefficients [[Haykin](http://localhost:8888/notebooks/index.ipynb#Literature)]

\begin{align}
\nabla_\mathbf{h} J &= -2 E \left\{ x[k-1] (x[k] - \mathbf{h}^T[k] \mathbf{x}[k-1]) \right\} \\
&= - 2 \mathbf{r}[k] + 2 \mathbf{R}[k-1] \mathbf{h}[k]
\end{align}

where

\begin{align}
\mathbf{R}[k] &= E \{ \mathbf{x}[k] \mathbf{x}^T[k] \} \\
\mathbf{r}[k] &= E \{ \mathbf{x}[k-1] x[k] \}
\end{align}

denote the $N \times N$ auto-correlation matrix and $N \times 1$ cross-correlation vector, respectively. The elements of the auto-correlation matrix $\mathbf{R}[k]$ can be interpreted by expanding the outer product $\mathbf{x}[k] \mathbf{x}^T[k]$ and using the definition of the auto-correlation function (ACF)

\begin{equation}
\mathbf{R}[k] = \left[
\begin{matrix}
\varphi_{xx}[k,k] & \varphi_{xx}[k,k-1] & \dots & \varphi_{xx}[k,k-N+1] \\
\varphi_{xx}[k-1,k] & \varphi_{xx}[k-1,k-1] & \dots & \varphi_{xx}[k-1,k-N+1] \\
\vdots & \vdots & \ddots & \vdots \\
\varphi_{xx}[k-N+1,k] & \varphi_{xx}[k-N+1,k-1] & \dots & \varphi_{xx}[k-N+1,k-N+1]
\end{matrix}
\right]
\end{equation}

The matrix is composed from the values of the ACF $\varphi_{xx}[k_1, k_2]$ for all combinations of time indexes. It is symmetric with respect to its main diagonal. The elements on the main diagonal constitute the quadratic means for the respective time indexes. The elements of the cross-correlation vector $\mathbf{r}[k]$ are given as

\begin{equation}
\mathbf{r}[k] = \left[
\begin{matrix}
\varphi_{xx}[k-1,k] \\
\varphi_{xx}[k-2,k] \\
\vdots \\
\varphi_{xx}[k-N,k]
\end{matrix}
\right]
\end{equation}

As the MMSE solution constitutes a convex optimization problem, the minimum of the cost function is found by setting its gradient to zero, $\nabla_\mathbf{h} J = 0$. Rearranging terms this results in the *Wiener-Hopf* equation

\begin{equation}
\mathbf{R}[k-1] \mathbf{h}[k] = \mathbf{r}[k]
\end{equation}

If $\mathbf{R}[k-1]$ has full rank (which should hold due to the random signal assumption), the optimum filter coefficients in the MMSE sense are given as

\begin{equation}
\mathbf{h}[k] = \mathbf{R}^{-1}[k-1] \mathbf{r}[k],
\end{equation}

i.e. solving the Wiener-Hopf equation by direct matrix inverse.

Note that various alternative solutions to the Wiener-Hopf equation have been developed which avoid the direct inversion of the auto-correlation matrix. For instance the [least-mean squares (LMS) filter](https://en.wikipedia.org/wiki/Least_mean_squares_filter) which uses steepest gradient descent to solve for $\mathbf{h}[k]$. This is also known as *Widrow-Hopf* solution.

### Example - Linear Prediction of a Speech Signal

In the following example, the principle of forward linear prediction is applied to the recording of a speech signal. The recording contains the stationary part of the vocal 'o'. This constitutes a random signal which can be considered reasonably well as wide-sense stationary (WSS). It follows from the properties of the ACF that

\begin{align}
\mathbf{R}[k] &= \mathbf{R} \\
\mathbf{r}[k] &= \mathbf{r} \\
\mathbf{h}[k] &= \mathbf{h} = \left[ h[1], h[2], \dots, h[N] \right]^T
\end{align}

where $h[k]$ denote the time-invariant weights of the predictor. As we only have access to one recorded instance of the vocal, we inherently assume wide-sense ergodicity. The expectations are computed by averaging with respect to time.

#### Computation of prediction filter

First the signal is loaded, conditioned and truncated

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.io import wavfile

L = 32768  # total number of samples of signal
N = 8  # order of predictor (number of FIR coefficients)

# read and truncate audio file
fs, x = wavfile.read('../data/vocal_o_8k.wav')
x = np.asarray(x, dtype=float)/2**15  # 16 Bit integer -> float
x = x[:L]
```

The auto-correlation matrix and the cross-correlation vector are estimated by averaging with respect to time. For instance

\begin{equation}
\mathbf{R} \approx \frac{1}{L-N+1} \sum_{k = N}^{L} \mathbf{x}[k-1] \mathbf{x}^T[k-1]
\end{equation}

where $L$ denotes the total number of samples of $x[k]$.

```python
R = np.zeros(shape=(N, N))
r = np.zeros(shape=(N, 1))

for k in np.arange(N, L):
    xk = np.expand_dims(np.flip(x[k-N:k]), 1)
    R += 1/(L+2-N) * xk * xk.T
    r += 1/(L+2-N) * xk * x[k]
```

Let's inspect the estimated auto-correlation matrix by plotting its values within a surface plot

```python
plt.figure(figsize=(3, 3))
plt.matshow(R)
plt.title(r'Estimate of auto-correlation matrix $\mathbf{R}$')
plt.colorbar(fraction=0.045)
plt.xlabel('sample shift')
plt.ylabel('sample shift');
```

    <Figure size 288x288 with 0 Axes>

As expected, the elements on the main diagonal have the largest values. The values decay with an increasing distance of the minor diagonals. Hence, the statistical dependencies between samples at two different time instants of the signal $x[k]$ decrease with an increasing distance between the samples. These statistical dependencies are exploited for the prediction of the current signal value from past ones.

Now the filter coefficients are computed and plotted.

```python
h = np.linalg.inv(R) @ r
h = np.squeeze(h)

plt.figure(figsize=(8, 3))
plt.stem(h, basefmt='C0:')
plt.xlabel(r'$k$')
plt.ylabel(r'$h_k$')
plt.grid()
```

#### Forward prediction

The value of the signal $x[k]$ at time-instant $k$ is predicted from its past $N$ values by evaluating the equation given above. This essentially constitutes a linear convolution $\hat{x}[k] = x[k] \ast h_{\mathrm{fp}}[k]$ with the forward prediction filter

\begin{equation}
h_{\mathrm{fp}}[k] = \begin{cases} 0 & \text{for } k=0 \\ h[k] & \text{for } 0 < k < N \end{cases}
\end{equation}

In order to evaluate the predictor, the predicted signal $\hat{x}[k]$ is computed for all time instants $k$ and plotted in comparison with the signal $x[k]$.

```python
hfp = np.concatenate(([0], h))
xp = np.convolve(x, hfp)[:L]

plt.figure(figsize=(8, 4))
plt.plot(x, label=r'signal $x[k]$', linestyle='--')
plt.plot(xp, label=r'predicted signal $\hat{x}[k]$', alpha=.8)
plt.xlabel(r'$k$')
plt.axis([10000, 10500, -.6, .6])
plt.legend()
plt.grid()
```

As the difference between the actual signal and its prediction is quite low, the error $e[k]$ is computed and plotted for inspection.

```python
e = x - xp

plt.figure(figsize=(8, 4))
plt.plot(e)
plt.xlabel(r'$k$')
plt.ylabel(r'$e[k]$')
plt.axis([10000, 10500, -.015, .015])
plt.grid()
```

The amplitude of the error is much lower than the amplitude of the signal. This shows that the signal can be well predicted by the computed predictor. In order to quantify this, the variance of the signal and the error is computed as measure of their average power. Note, both signals are assumed to be zero-mean.

```python
print('Variance of signal {:2.6f}'.format(np.var(x)))
print('Variance of error {:2.6f}'.format(np.var(e)))
```

    Variance of signal 0.060221
    Variance of error 0.000011

#### Prediction from residual error

The error signal may be rewritten as

\begin{align}
e[k] &= x[k] - \mathbf{h}^T \, \mathbf{x}[k-1] \\
&= h_{\mathrm{pe}}[k] \ast x[k]
\end{align}

where

\begin{equation}
h_{\mathrm{pe}}[k] = \begin{cases} 1 & \text{for } k=0 \\ - h[k] & \text{for } 0 < k < N \end{cases}
\end{equation}

denotes the prediction-error filter. Taking the $z$-transform of the error signal yields

\begin{equation}
E(z) = H_{\mathrm{pe}}(z) \cdot X(z)
\end{equation}

where for instance $E(z) = \mathcal{Z} \{ e[k] \}$. From this result it can be concluded that the signal $x[k]$ can be computed from the error signal $e[k]$ by inverse filtering with the prediction-error filter

\begin{equation}
x[k] = \underbrace{\mathcal{Z}^{-1} \left\{ \frac{1}{H_{\mathrm{pe}}(z)} \right\}}_{h_{\mathrm{pe}}^{-1}[k]} \ast e[k]
\end{equation}

The inverse of the prediction-error filter $h_{\mathrm{pe}}^{-1}[k]$ constitutes an pole-only (recursive) filter as the prediction filter is a zero-only (non-recursive) filter. In the following, the signal $x[k]$ is computed from the error signal $e[k]$ and plotted.

```python
from scipy.signal import lfilter

hpe = np.concatenate(([1], -h))
xe = lfilter([1], hpe, e)

plt.figure(figsize=(8, 4))
plt.plot(xe)
plt.xlabel(r'$k$')
plt.ylabel(r'artifical speech signal $x[k]$')
plt.axis([10000, 10500, -.6, .6])
plt.grid()
```

#### Application - Speech synthesis using an artificial glottal signal

Linear prediction is used as [linear predictive coding](https://en.wikipedia.org/wiki/Linear_predictive_coding) (LPC) in the coding and synthesis of speech signals. The latter application is briefly presented in the sequel. It bases on the [source-filter model](https://en.wikipedia.org/wiki/Source%E2%80%93filter_model) of human speech production. Here speech production is modeled as a sound source filtered by a linear acoustic filter. In the case of vocals, this sound source are the [vocal cords](https://en.wikipedia.org/wiki/Vocal_cords) which generate the periodic glottal signal. The [vocal tract](https://en.wikipedia.org/wiki/Vocal_tract) may be interpreted as an acoustic filter. Vocals are articulated by changing the air flow through the vocal cords, their state as well as the configuration of the vocal tract. Due to its acoustical properties, the vocal tract can be represented as a pole-only filter.

The basic idea of LPC-based speech synthesis is, that the inverse of the prediction-error filter is an pole-only filter. When interpreting the error signal $e[k]$ as glottal signal in the source-filter model, the inverse of the prediction-error filter can be used to generate the speech signal $x[k]$. A synthetic vocal may be generated by using a synthetic glottal signal. A simple model for the glottal signal is a mean-free periodic rectangular signal with a duty cycle of 1:10. The signal is generated and plotted for illustration.

```python
from scipy.signal import square

def glottal_signal(f0):
    '''Generates a synthetic glottal signal with fundamental frequency f0 in Hertz.'''
    w0 = 2 * np.pi * f0
    t = 1/fs * np.arange(L)
    g = 1/2 * (1 + square(w0*t, duty=1/10))

    return - g + 0.1

g = 0.0055 * glottal_signal(100)

plt.figure(figsize=(8, 4))
plt.stem(g[:100])
plt.xlabel(r'$k$')
plt.ylabel(r'glottal signal $g[k]$')
plt.grid()
```

The spectral contents of the error signal $e[k]$ and the synthetic glottal signal $g[k]$ are compared by estimating their power spectral densities (PSDs) using the [Welch technique](../spectral_estimation_random_signals/welch_method.ipynb).

```python
from scipy.signal import welch

f, Pee = welch(e, fs, nperseg=1024)
f, Pgg = welch(g, fs, nperseg=1024)
plt.plot(f, Pee,
         label=r'$\hat{\Phi}_{ee}(e^{j \Omega})$ error signal')
plt.plot(f, Pgg,
         label=r'$\hat{\Phi}_{gg}(e^{j \Omega})$ synthetic glottal signal')

plt.xlim([0, 1000])
plt.xlabel('frequency in Hertz')
plt.legend()
plt.grid()
```

The spectra of both signals constitute line-like spectra due to their periodicity. However, the levels of the spectral lines differ. This becomes most prominent around 600 Hertz. The synthetic glottal signal is now filtered with the inverse prediction-error filter in order to generate the synthetic speech signal.

```python
xa = lfilter([1], hpe, g)

plt.figure(figsize=(8, 4))
plt.plot(xa)
plt.xlabel(r'$k$')
plt.ylabel(r'synthetic signal $x[k]$')
plt.axis([10000, 10500, -1, 1])
plt.grid()
```

Visual comparison with the recorded speech signal shown above yields that the coarse structure of both is quite similar. The differences are a consequence of the simple glottal signal used. Both signals may also be compared aurally by listening to them.

```python
wavfile.write('artificial_vocal.wav', fs, np.int16(xa*2**15))
```

**Recorded vocal**

<audio src="./../data/vocal_o_8k.wav" controls>Your browser does not support the audio element.</audio>[../data/vocal_o_8k.wav](../data/vocal_o_8k.wav)

**Synthesized vocal**

<audio src="./artificial_vocal.wav" controls>Your browser does not support the audio element.</audio>[./artificial_vocal.wav](./artificial_vocal.wav)

**Copyright**

This notebook is provided as [Open Educational Resource](https://en.wikipedia.org/wiki/Open_educational_resources). Feel free to use the notebook for your own purposes. The text is licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/), the code of the IPython examples under the [MIT license](https://opensource.org/licenses/MIT). Please attribute the work as follows: *Sascha Spors, Digital Signal Processing - Lecture notes featuring computational examples*.


# 3 Modelling your data

What is modelling? Let me attempt (unsuccessfully) an all-encompassing definition.

"*An **approximate** representation of a physical process, soluble by analytical or numerical methods, for the purpose of understanding, investigating, or predicting said process.*"

Shamelessly taking advantage of this course as a platform for my opinions, I'll claim there are **three basic motivations** to use a model:

1. ***Modelling for Insight***. The goal being to obtain qualitative understanding of a physical process.
2. ***Inverse Modelling***. The goal being to constrain the value of a model's physical parameters. Something that is otherwise difficult to measure. E.g., seismic tomography, magnetotelluric inversion. *Also called: inversion, curve-fitting, model calibration.*
3. ***Modelling for Prediction***. The goal being to constrain past or future outcomes of a physical process. *Also called: interpolation and extrapolation.*

Like the previous two modules, we'll touch on **some** of these topics but only **incompletely**. This is just an entry point into a much larger world...

## 3.1 Defining a model

What exactly are the fundamental components of a computer model?

While models can get quite complex indeed (global climate models, I'm looking at you), at it's core, we can strip things back to four main components.

1. The **independent variables**. These are **inputs** to the simulator. Examples are: the three spatial coordinate directions and time ($x$, $y$, $z$ and $t$).
2. The **parameters**. These are also **inputs**. They are physical properties, some of which can be measured. Examples are: density of rock, viscosity of water.
3. The **simulator**. A set of equations governing the physical process of interest and the numerical architecture to solve them. Examples are: [TOUGH2](http://esd1.lbl.gov/research/projects/tough/) for solving geothermal problems, [RSQSim](http://srl.geoscienceworld.org/content/83/6/983) for solving earthquake problems.
4. The **dependent variables**. These are the **outputs** of the simulator. They are physical quantities. Examples are: pressure and temperature in a geothermal reservoir, displacement on a fault.

### 3.1.1 A super simple example

Here is one of the most basic models that can be conceived, linear variation of temperature with depth

$$ T = m z+T_0, $$

It has all four basic components:

- An independent variable, $z$, the depth. **Check** $\checkmark$.
- Some parameters, $m$ and $T_0$, the geothermal gradient and the surface temperature. **Check** $\checkmark$.
- A simulator. This one is trickier to spot. In this case, it is the fact we have expressed $T$ as a linear function of $z$. If you prefer, think of this as a recipe or instruction set for obtaining $T$ from $z$, $m$ and $T_0$: "*multiply $z$ and $m$ and then add $T_0$, voila.*" **Check** $\checkmark$.
- A dependent variable, $T$, the temperature. **Check** $\checkmark$.

Because it is quite simple, we can **implement** this model as a Python function.

```python
# Define the linear temperature model as a Python function. Key syntax elements here are:
# - def (a reserved keyword indicating that what follows will be a function)
# - temperature_model (the NAME of our function, we will use this to CALL the function)
# - round brackets (as opposed to SQUARE brackets which are used for lists and arrays!)
# - z, m, T0 (inputs to the function, variables it will use in its calculations)
# - T0=0 (this means that the third input, T0, is NOT REQUIRED, and if it is left out, will be set to 0)
# - ''' text ''' (the doc-string, kind of like a manual on the function's use)
# - return (the statement that ENDS the function and SENDS a value out of it)

def temperature_model(z, m, T0=0):
    ''' This is a doc-string. It is NOT required, but convention to include.

        It is essentially one large comment that carries information about the function. For example:
        ---
        Return temperature that is linear with depth.

        Inputs:
        -------
        z  : float
            depth
        m  : float
            geothermal gradient (linear coefficient)
        T0 : float
            surface temperature (constan coefficient, default = 0)

        Returns:
        --------
        T : float
            temperature

        Notes:
        ------
        Here I might include some more information about the model.

        Examples:
        ---------
        >>> temperature_model(z=10.0, m=2.0, T0=25.0)
        45.
    '''
    T = m*z + T0
    return T
```

Now that it is defined, let's see how the function works in practice.

```python
# a simple example, and the output should make sense (10 times 2 equals 20, plus 25 equals 45)
T = temperature_model(z=10.0, m=2.0, T0=25.0)
print(T)
```

Think about varying the value of $z$, and getting values of temperature for constant values of $m$ and $T_0$

```python
T10 = temperature_model(10, 2, 25)          # note, I don't need to do *input = *, providing I get the order of the arguments correct
T20 = temperature_model(20, 2, 25)
T30 = temperature_model(30, 2, 25)
T40 = temperature_model(40, 2, 25)
T50 = temperature_model(50, 2, 25)

print(T10, T20, T30, T40, T50)
```

This model is now answering the question "*What does temperature look like as depth increases?*". This is potentially a "**modelling for insight**" or "**modelling to predict**" kind of question.

Let's think now about varying the value of $m$, and getting values of temperature for constant values of $z$ and $T_0$

```python
T0_5 = temperature_model(10, 0.5, 25)
T1 = temperature_model(10, 1, 25)
T2 = temperature_model(10, 2, 25)
T3 = temperature_model(10, 3, 25)
T5 = temperature_model(10, 5, 25)

print(T0_5, T1, T2, T3, T5)
```

The model is now answering the question "**What do different values of $m$ predict the temperature to be at 10 m depth?**"

If I **measured** the temperature at 10 m depth and discovered it was 47 degC, what would you conclude about the value of $m$? This is an "**inverse modelling**" question.

### 3.1.2 A more complex example (your turn)

A geothermal reservoir can be conceptualised as a **box**.

- Water can **exit** the box (usually from wells drilled into the middle). When it does, the pressure goes **down**.
- When the pressure drops, more water will try to **enter** the box (usually at the base or the sides). When it does this, the pressure goes **up**.

A **lumped parameter model** (LPM) describes the average pressure evolution of the reservoir as a whole. **Write a function** to implement the differential equation below, a particular type of LPM (this model comes from [Fradkin et al. [1981]](http://onlinelibrary.wiley.com/doi/10.1029/WR017i004p00929/full)):

$$ \frac{dP}{dt} = a P - b q + c \frac{dq}{dt}$$

were $P$ is the pressure change from the initial value (not the absolute pressure), $q$ is the extraction rate from the reservoir, and $a$, $b$ and $c$ are unknown parameters that depend on the reservoir.

```python
# **your function here
#
# **can't remember what to do? some hints
# ** - give your function a name
# ** - remember syntax - def, :, (), return etc.
# ** - the output should be the lefthand side of the equation
# ** - there should be six inputs/arguments - what should they be?
# ** - OPTIONAL: include a step to check that a<0, and raise an error if it is not (HINT: see Python101.ipynb, Section 0.2)

```

Use your function to calculate the value of $dP/dt$ for $a=b=c=1$, $P = 10$, $q = 5$, and $dq/dt = 0$.

## 3.2 Solving a model

Recall earlier, when we defined the model components. We said that the **simulator** comprised BOTH (1) a set of equations to describe the **physical process** AND (2) some **numerical architecture** to solve those equations.

What you defined above - an ordinary differential equation (ODE) for a LPM - was the first part. Now we will look at the tools Python has to solve this equation.

### 3.2.1 Analytically

***First, can we solve the model by hand?***

Imagine the case of constant production from the reservoir, that is, $q$ is a constant value, $q_0$, and therefore $dq/dt=0$. Our ODE can then be written in the form

$$ \frac{dP}{dt} = aP - bq_0.$$

For the initial condition, $t=0,$ $P=0$, this ODE has the straightforward solution

$$ P = \frac{bq_0}{a}(1- e^{at}).$$

Providing $a<0$ (and it always is, unless you live on a planet where the laws of physics don't apply), then this solution tends exponentially toward the steady state $P_{ss}=bq_0/a$.

Let's take a quick peek!

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

# a function can be defined and returned all on one line (but this restricts the calculation to one line)
def P(t,a,b,q0): return b*q0/a*(1-np.exp(a*t))     # define the analytic solution as a function
t = np.linspace(0,10,101)                          # an array of times to plot the solution
a,b,q0 = [-.5,1,3.]                                  # some parameters
f,ax = plt.subplots(1,1,figsize=(10,5))
ax.plot(t, P(t,a,b,q0), label='pressure')          # plot the solution

# plot the steady state as a horizontal line
xlim = ax.get_xlim()                               # get the plotting limits
p_ss = b*q0/a
ax.plot(xlim, [p_ss, p_ss], 'r--', label = 'steady state')
ax.legend()
```

***Modelling for insight:***

- ***What happens if we change the sign of $q_0$? Physically, what does this correspond to and why does the answer make sense?***

- ***Which parameter controls how long it takes the reservoir to reach steady-state?***

- ***If we increase $q_0$ how does $P$ change? Does this make sense?***

Sometimes, a good way to answer questions like those above is just to "plug-and-play" with a model. This can help hone your physical intuition for the process: "*if I twiddle with this knob, then the output changes in this way.*"

### 3.2.2 Numerically

***Okay, great. But most models can't be solved by hand.***

Imagine that $q(t) = 1 + sin(t)$ (extraction from the reservoir oscillates in time), in which case, $dq/dt = cos(t)$, and it is no longer possible to solve the LPM by hand. What then?

Python includes a suite of methods for solving ODEs numerically. These won't give you a nice equation like above. But they WILL give you something you can plot, and then think about.

First, let's define another ODE for the oscillating extraction rate.

```python
def lpm_oscillating(p, t, a, b, c):
    return a*p - b*(1+np.sin(t)) + c*np.cos(t)
```

Now, let's solve this ODE using some tools in the SciPy module (Scientific Python, like NumPy on steroids)

```python
from scipy.integrate import odeint           # get the ODE tools
```

Note, `odeint` expects that the function handle (name of the function) we pass it as an input conforms to a particular template

`f(y, t, p1, p2, ...)`, i.e., the dependent variable is the first input, the independent variable is the second, additional parameters follow.

Let's set up a few more elements before we solve the ODE.

The numerical method works by "stepping" along the solution. Basically, for very small steps, Python calculates:

1. What is the current value of $P$ and $t$?
2. Given these, what is the value of $dP/dt$?
3. Let's use this value of $dP/dt$ to "hop forward" a little bit, $dt$. That is, $t_{new}=t+dt$ and $P_{new} = P + dP/dt \times dt$.
4. Repeat steps 1-3 many times.

***Execute the cell below to compute a numerical solution to this problem.***

```python
from scipy.integrate import odeint           # get the ODE tools
t = np.linspace(0,30,300)                    # array of times to get solution
p0 = 0.                                      # we need to know where the solution starts, the initial value
                                             # as before, I am assuming that P = 0 when t = 0
pars = (-0.1, 1., 1.0)                       # we need to set the parameter values, in this case I am choosing
                                             # a = -0.1, b = 1 and c = 1

# now just pass all this info into the ODE solver
p = odeint(lpm_oscillating, y0=p0, t=t, args=pars)

# plot the solution
f,ax = plt.subplots(1,1,figsize=(10,5))
ax.plot(t, p)
ax.set_ylabel('pressure change')

# Here's a nifty thing, we can create a duplicate axis over the top of an existing one and MAKE
# them share the x axis. This way, I can plot one quantity on the left axis, and another on the right,
# while still allowing that they have different scales!
ax2 = ax.twinx()
ax2.plot(t, 1+np.sin(t),'r-')
ax2.set_ylabel('extraction rate')
ax2.set_xlabel('time')
```

***Modelling for insight***

- ***The solution still exhibits exponential decay, although there is now an oscillating component***
- ***Play with the values for $a$, $b$ and $c$ and figure out which parameters control what aspects of the solution***

Note, to rerun the model for different parameters, make a change to `pars=` and then rerun this cell.

## 3.3 Calibrating a model

Model calibration is the process of **modifying your model so that it more-closely resembles reality.**

There are two ideas implicit here:

1. A mechanism to **modify** the model.
2. A means of assessing its **semblance to reality**.

### 3.3.1 A model of Wairakei geothermal system

We'll extend the LPM model from before to an example with real data: the Wairakei geothermal system, which has been producing good, clean geothermal electricity since 1955.

There are two sets of data available to us: (1) annual extraction rates from the field, $q$, and (2) average pressure drawdown, $P$.

***Execute the cell below to load and plot the Wairakei data.***

```python
# load the data (this should look familiar!)
tq, q = np.genfromtxt('../data/wk_production_history.csv', delimiter=',', unpack=True)
tp, p = np.genfromtxt('../data/wk_pressure_history.csv', delimiter=',', unpack=True)

# plot the data (so should this!)
f,(ax1,ax2) = plt.subplots(1,2,figsize=(10,5))
ax1.plot(tq,q,'b-')
ax1.set_xlabel('time [yr]')
ax1.set_ylabel('production rate [kg/s]')

ax2.plot(tp,p,'ro')
ax2.set_xlabel('time [yr]')
ax2.set_ylabel('pressure change [bar]');
```

In the LPM model we developed before, there is no requirement that $q$ be expressed as some nice analytic function. We can use the data in the lefthand plot above (computing its $dq/dt$ by **finite differences**).

The cell below implements an LPM model for the Wairakei data

```python
# the details of this model aren't super important
# ...unless you're feeling adventurous, then have at it
def lpm_wk(t,a,b,c,q_future = None):
    ''' Lumped parameter model for calibration.

    Inputs:
    -------
    t : array-like
        time (independent variable)
    a : float
        parameter coefficient of first term of ODE
    b : float
        parameter coefficient of second term of ODE
    c : float
        parameter coefficient of third term of ODE
    q_future: float
        constant flow rate for post calibration period (for model prediction)

    Notes:
    ------
    To be used as an input to curve_fit (automatic Python calibration) this function MUST be defined
    with the independent variable as the first input, and parameters as subsequent inputs.
    '''
    # rescale the parameters
    a = a*1.e-8               # [s-1]
    b = b*1.e-5               # [m-1.s-2]
    c = c*1.e3                # [m-1.s-1]

    dt = 365*24*3600.         # time step [s]
    p0 = 56.26e5              # initial reservoir pressure [Pa]

    # load production history
    tq,q = np.genfromtxt('../data/wk_production_history.csv', delimiter = ',', unpack=True)
    # append future flow rate if appropriate
    if q_future is not None:
        tq = np.concatenate([tq, np.arange(tq[-1]+1, tq[-1]+51, 1)])
        q = np.concatenate([q, q_future*np.ones(50)])

    # solve the ODE (we're not using scipy ODE method now)
    p = [p0]                  # initial value [Pa]
    for i in range(len(q)-2): # iteration using improved Euler method
        y0 = p0-p[-1]
        f0 = -a*(y0)+b*q[i]+c*(q[i+1]-q[i])/dt
        y1 = y0 + dt*f0
        f1 = -a*(y1)+b*q[i+1]+c*(q[i+2]-q[i+1])/dt
        y2 = y0 + (dt/2)*(f0+f1)
        p.append(p0-y2)

    # last step, we'll interpolate (piecewise linear) the solution onto the array of input times
    pi = np.interp(t, tq[:-1], p)
    return pi*1.e-5    # reservoir pressure [bars]
```

### 3.3.2 Comparison with observation

**So how does the model perform?**

A comparison against the data should indicate quality. Remember, the **model predicts pressure changes** and we have **measurements of pressure change.**

The cell below plots the data and the model on the same axes.

```python
# we'll wrap the plot commands up in a function, this will be helpful for later
def plot_wk_model(a,b,c,q_future=None):
    f,ax = plt.subplots(1,1,figsize=(10,5))
    tp, p = np.genfromtxt('../data/wk_pressure_history.csv', delimiter=',', unpack=True)
    ax.plot(tp, p, 'ro', label='data')                           # plot the data
    if q_future is None:
        # plot model up to end of the calibration period
        ax.plot(tp, lpm_wk(tp,a,b,c), 'k-', label='model')
    else:
        # plot calibration and future period
        tp = np.linspace(tp[0], tp[-1]+50., 101)
        ax.plot(tp, lpm_wk(tp,a,b,c,q_future), 'k-', label='model')
    ax.legend()
    ax.set_xlabel('time')
    ax.set_ylabel('pressure decline')
    plt.show()

# plot the model for a = 5, b = 8, c = -3
plot_wk_model(5, 8, -3)
```

**What do you reckon? Good model? Bad model?**

Of course, the answer lies somewhere on a sliding scale between **perfect model** and **crime against humanity**. When assessing the quality of your own model, it might pay to think about the following points:

- Does the model do an **okay** job of fitting the data **on average**, without the model having to be **overly complicated?** (if yes, this may be the best outcome).
- Does the model pass through all the data points **exactly?** (DANGER: you are probably fitting the noise, as much as the data).
- Is the effort to **improve** the model fit - say, to go from really good to almost perfect - actually worth it? (There's always a trade-off here).

My professional assessment: I have seen much worse models than that above... but there's definitely room for improvement.

The concept of **improving** a model by **comparing** it to data is the essence of **calibration**. The comparison above is **qualitative**, but we'll soon look at quantitative methods.

#### <neat> Interpolation

Sometimes a model won't output a prediction at the *exact* time or location that we have corresponding observations. How then to make a comparison between model and data?

This problem can be addressed by **interpolation**. Say we have model output at two times (or locations), $t_i$ and $t_{i+1}$, with corresponding output, $y_i$ and $y_{i+1}$. Further imagine that we made an observation for comparison, $\tilde{y}_j$, at $t_j$, which falls between $t_i$ and $t_{i+1}$. Then *one method* of interpolation is to assume that the model is a straight line between the points $(t_i,y_i)$ and $(t_{i+1},y_{i+1})$. In which case, the model prediction of $\tilde{y}_j$ is

\begin{equation}
y_j = \frac{t_{i+1}-t_j}{t_{i+1}-t_i}y_i+\frac{t_j-t_i}{t_{i+1}-t_i}y_{i+1}
\end{equation}

The good news is, you don't have to implement this equation each time you need to do some interpolation. Python provides some nice built-in functions.

**Execute the cells below.**

First, let's plot the output of a model alongside some data.

```python
# define some data
ti = np.array([2.5, 3.5, 4.5, 5.6, 8.6, 9.9, 13.0, 13.5])
yi = np.array([24.7, 21.5, 21.6, 22.2, 28.2, 26.3, 41.7, 54.8])

# define a model
def model(t,a,b,c):
    ''' Implement a simple quadratic model.

        y = a*t^2 + b*t + c
    '''

    return a*t**2 + b*t + c

# define some output times and evaluate the model
tj = np.linspace(2., 14., 7)
yj = model(tj, 0.45, -5, 34)

# plotting
f,ax = plt.subplots(1,1,figsize=(10,5))
ax.plot(ti,yi,'ks',label = 'data')
ax.plot(tj,yj,'wo', mec = 'k', label = 'model')
ax.set_xlabel('t')
ax.set_ylabel('y')
ax.legend();
```

We'll use NumPy's built in linear interpolation function to interpolate the model values (open circles) where data have been collected (black squares).

```python
# interpolation
# - first input, where we want to interpolate the model TO (the times of the data)
# - second input, the TIMES of the model
# - third input, the VALUES of the model
y_int = np.interp(ti, tj, yj)

# plotting
f,ax = plt.subplots(1,1,figsize=(10,5))
ax.plot(ti,yi,'ks',label = 'data')
ax.plot(ti, y_int,'ws', mec = 'k', label = 'interpolated model')
ax.set_xlabel('t')
ax.set_ylabel('y')
ax.legend();
```

Of course, because our model in this instance is a simple analytic function, we could have just evaluated it directly at the data, $t_i$. However, in the more general case that a model is solved numerically, this won't always be possible.

Other methods for interpolating data include [polynomial fitting](https://en.wikipedia.org/wiki/Polynomial_regression) and [cubic spline interpolation](https://en.wikiversity.org/wiki/Cubic_Spline_Interpolation). Different methods may be more or less appropriate depending on the amount of data or the use case.

Interpolation doesn't just have to be between model and data. If you wish to compare data collected at different intervals (sampling rates), then you can interpolate one (or both) to a common set of points.

```python
# **to do**
# Calculate the root mean square of the differences between the data and the interpolated model from above.
# Plot the model (quadratic) and compare this to the interpolant (piecewise linear between the evaluated model output).
```

#### </neat>

### 3.3.3 Ad-hoc calibration

**What could we do to *improve* the model?**

- Change the data so they match the model? (Absolutely not!)
- Change the model equations? (Sometimes...)
- **Change the parameters? (Yes, this is what you should try first.)**

I will use the `interact` function we introduced in the previous notebook, to quickly plot the model for different values of the parameters, `a`, `b` and `c`.

```python
from ipywidgets import interact, fixed
interact(plot_wk_model, a = (0., 6, 0.1), b = (0, 6, 0.1), c = (-5, 5, 0.1), q_future = fixed(None));
```

***Play with the slider bars above to:***

- **Replicate our first (rather poor) attempt at modelling these data.**
- **Obtain the best-fitting model.**

This process is called **ad-hoc calibration**. Changing parameters in a way you think will improve the model. After each change, you have to **run the model again** and compare the output to data. This model runs so quickly you barely notice. However, some models takes hours, days, weeks to run - in which case, you need to choose your parameters carefully!

### 3.3.4 Automatic model calibration

Depending on your understanding of the model and its underlying physics, ad-hoc calibration falls somewhere between aimless stumbling and purposeful striding around **parameter space**.

The drawback of ad-hoc calibration is that it still requires you to exercise the grey-matter at each step:

- *look at the model, look at the data*
- "*hmmmmm*"
- *make a change, run the model*
- "*hmmmmm*"
- *etc.*

We can retire our brain from the process completely by using an **automatic** calibration technique. This requires that we first **quantify** how well the model compares to the data.

#### Least-squares residual

Perhaps the most popular way of comparing a model to data: the sum of squares. If we denote the independent variables, $\mathbf{x}$, the parameters, $\boldsymbol{\theta}$, the model $y=f(\mathbf{x},\boldsymbol{\theta})$, and the observations $(\tilde{\mathbf{x}}_i, \tilde{y}_i) = [(\tilde{\mathbf{x}}_1, \tilde{y}_1),(\tilde{\mathbf{x}}_2, \tilde{y}_2),\dots(\tilde{\mathbf{x}}_n, \tilde{y}_n)]$, then the least-squares residual is expressed

$$ r(\boldsymbol{\theta})=\sum\limits_{i=1}^n \left( f(\mathbf{x}_i,\boldsymbol{\theta}) - \tilde{y_i} \right)^2.$$

This equation says nothing more than "*take the difference between the model prediction and the data, square the difference, add all these together for all data points*". For the purpose of calibration, $r$ is useful because:

1. It is a single number representing the quality of a model.
2. It depends on the parameters of the model.

So. We make a change to $\boldsymbol{\theta}$ (the parameters). $r$ goes up or down. On that basis, **Python makes the decision** on how to choose a different value of $\boldsymbol{\theta}$. Let's see how this works using the `curve_fit` function.

```python
from scipy.optimize import curve_fit                     # the function we'll be using for automatic calibration

# the inputs to curve_fit are, in order
# - a function, representing the model (see above definition of lpm_wk)
# - an array of independent variables, corresponding to the data/measurements
# - an array of dependent variables, the data/measurements
# - an initial guess of the parameters (in our case, initial a, b and c)
# there are two outputs: the best-fitting parameter values and their covariances (we'll ignore the second part)

# let's see how it works!
    # load in the data again
tp, p = np.genfromtxt('../data/wk_pressure_history.csv', delimiter=',', unpack=True)
    # define an initial guess for the parameters
par_i = [5, 8, -3]
par, pcov = curve_fit(lpm_wk, tp, p, par_i)
print(par)
```

How close are these values, for `a`, `b` and `c`, to those you obtained from ad-hoc calibration (with `interact`?)

Let's plot this **best-fit** model and see how it looks.

```python
a_fit, b_fit, c_fit = par                 # 'unpack' the list of best-fit parameters to individual variables
plot_wk_model(a_fit, b_fit, c_fit)
```

**Hey! Not bad!**

## 3.4 Inverse modelling

Some things are **difficult** to measure.

In geoscience, it's often because the quantity we're interested in, say $X$, is obscured by the passage of time or several kilometres of rock.

Inverse modelling (or inversion) skirts this problem by:

1. instead measuring a different, more easily accessed quantity, $Y$, and...
2. relating this to the quantity of interest, $X$, using a model, $Y = f(X)$.

Because $X$ is the parameter of a model, we can **infer** it's value by measuring $Y$ and then calibrating the model on these data.

A good example is geophysical sensing, say magnetotelluric surveying.

1. We CAN'T measure the conductivity of rock 10 km underground directly (although we might want to).
2. However, we CAN measure electric and magnetic fields at Earth's surface.
3. And, we CAN use a model to link those surface measurements to the conductivity at depth (using Maxwell's equations).
4. By CALIBRATING the model, we learn the values of the deep conductivity (the model parameters) indirectly from the data (surface measurements).

### An example: the lumped parameter model (again!?)

As a simple illustration of the principle, consider again our efforts in the previous section on calibration. Whether you attempted ad-hoc or automatic calibration, you **should** have obtained parameter values of *about* `a`=0.2, `b`=0.5, and `c`=0.8.

Inspecting the [original paper](http://onlinelibrary.wiley.com/doi/10.1029/WR017i004p00929/full), we can interpret $a$, $b$ and $c$ in terms of physical quantities

$$ \frac{g}{(1-S_0)A\phi} =\frac{b^2}{b-ac}.$$

Where $g$ is gravity (9.8$\,$ms$^{-2}$), $A$ is lateral cross-sectional area of the Wairakei reservoir (15$\,$km$^2$), $S_0$ is a quantity called residual saturation ($\approx 0.3$). The remaining, undetermined parameter, $\phi$ is the reservoir porosity.

We can calculate its value by substituting values for the known ($g$, $A$, $S_0$) and fitted ($a$, $b$, $c$) parameters.

```python
# convert fitted parameters to SI values (see lpm_wk definition for more details)
a_SI = a_fit*1.e-8      # [/s]
b_SI = b_fit*1.e-5      # [/m /s^2]
c_SI = c_fit*1.e3       # [/m /s]

# other reservoir parameters (SI values)
g = 9.81                # [m/s^2]
A = 15.e6               # [m^2]
S0 = 0.3                # []

# calculate porosity
phi = (g/(A*(1-S0)))/(b_SI**2/(b_SI-a_SI*c_SI))
print(phi)
```

So about 12%.

Here's the important take-away. **At no point did we measure porosity directly.**

Instead, we measured **related** quantities, used a **model** to interpret these, and Python to construct the **inversion** procedure.

When people say ["*Science is Magic*"](https://en.wikipedia.org/wiki/Clarke%27s_three_laws), this is what they mean!

(*This code also implemented in script: [3_modelling/lpm_inversion.py](lpm_inversion.py) - try running it at the command line* `ipython lpm_inversion.py`)

## 3.5 Prediction with a model

Proceeding with our calibrated model - `a`=0.2, `b`=0.5, and `c`=0.8 - how can we use this information?

Well, one thing we could do is try to make a **prediction of the future.**

Consider two cases: (i) production from the reservoir continues at 750 kg/s for the next 50 years, and (ii) production is halted, and the geothermal system is allowed to recover.

Each case is implemented by **extending** the array `q` in `lpm_wk` to include production in the future at some fixed rate `q_future`.

```python
# case 1: production continues
plot_wk_model(a_fit, b_fit, c_fit, q_future = 750.)

# case 2: production stops
#plot_wk_model(a_fit, b_fit, c_fit, q_future = 0.)
```

***What is the maximum production rate if we wish to stabilize the reservoir pressure above 45 MPa?***

***If we stop production entirely, how long does the reservoir take to recover to 50 MPa?***

#### <neat> Uncertainty in models

So how certain are we about `a`=0.2, `b`=0.5, and `c`=0.8. It's close, but is it **exactly right?**

Probably not. In fact, **almost certainly not.** (What, 0.2000000000, 0.5000000000 and 0.8000000000 exactly?)

Therefore, a natural extension of our analysis above is, how do we think about and handle uncertainty in our models?

This question is a whole area of study in itself. People dedicate their academic careers to studying model uncertainty. The subject is simply too large for us to address here.

However, for those interested, I have included a [supplementary notebook](uncertainty.ipynb) that will let you dip your toes in, and start thinking about model uncertainty...

#### </neat>

# What now?

Congratulations, you're now a qualified level 3 Python Warlock (not really, I'm not a degree granting institution).

The next step is to start boldly introducing Python into your own work. Start with some simple low-stakes stuff! Be persistent and ask Google and StackOverflow for help.

It took me a good six weeks before I felt comfortable using Python, and I already had computing background (but not a swish introductory course like this!)

And in true Python spirit, I will leave you with some dumb in-jokes.

***Execute the cells below***.

```python
import this
```

```python
import antigravity
```

And if that's not enough XKCD for you...

```python
from matplotlib import pyplot as plt
plt.xkcd()
f,ax=plt.subplots(1,1,figsize=(10,5))
x=np.linspace(0,2.*np.pi,101)
ax.plot(x,np.sin(2*x), 'b-')
ax.set_xlabel('x')
ax.set_ylabel('sin(2x)');
```


# Introduction to Sympy
Sympy (http://www.sympy.org/en/index.html) is a symbolic mathematics package in Python.  It is used in this repository for deriving and explaning algorithms.  It is also used in QMCPACK to generate values for unit testing, and generating code in a few places.

This is not a general introduction to Sympy.  For that, see the [Tutorial](http://docs.sympy.org/latest/tutorial/index.html#tutorial).  This document is focused on the ways Sympy is used in this repository and in QMCPACK.

```python
from sympy import *
# Call the following so expressions are rendered nicely in Jupyter Notebooks
init_printing()
```

## Symbols

There are several way to create symbols.  The the most basic is 'Symbol'.

```python
a = Symbol('a')
a
```

```python
i = Symbol('i',integer=True)
i
```

```python
#  Create multiple symbols - input is string with symbol names separated by spaces
b,c,d = symbols('b c d')
b,c,d
```

```python
# Some special characters in symbols are recognized as TeX and rendered as such
a2 = Symbol('a_2')
a2
```

Symbols can be combined into expressions

```python
e = 2*a + 5*b + c*c
e
```

### Expression evaluation
Expressions are evaluated through substition.  This is done with the `subs` method. There are three ways to specific the substitutions
1. Single value - pass the old value and the new value to subs
2. A list of old value, new value pairs
3. A dictionary where the old values is the key, and the new value is the value

```python
e.subs(a,2)
```

```python
# List of old value, new value pairs
subslist = [(a,2),(b,1),(c,3)]
e.subs(subslist)
```

```python
# Dictionary of old value and new values
subsdict = {a:2, b:1, c:3}
e.subs(subsdict)
```

### Arrays and Matrices
There are several ways to represent indexed values
1. Indexed types (in the tensor module)
2. Concrete matrices.  These have a fixed size and store all the elements.
3. Symbolic matrices.   These represent a matrix more generically.  The size is specified, but could be a variable.  Individual elements are not stored.

```python
c = IndexedBase('c')
c[3]
```

```python
# Concrete matrix
A = Matrix( [[1,2],[a,4]])
A
```

```python
# Symbolic matrix
B = MatrixSymbol('B',3,3)
B
```

## Code generation

There are several way to turn Sympy expressions into executable code.  One reason is we want to evaluate expressions faster than substitution described above.  Or we want the code in Python or C++ to interface with or plug into a larger code.

See the Scipy 2017 Tutorial for more on Sympy code generation (http://www.sympy.org/scipy-2017-codegen-tutorial/ )

#### Lambdify
Using 'lambdify' can turn an expression into an executable python function.  It takes two arguments - the first is the input variable (or list of variables), and the second is the expression to convert.

```python
e = 2*a + 3
f1 = lambdify(a, e)
f1(3)
```

```python
e = 2*a*b
f2 = lambdify([a,b],e)
f2(1,2)
```

The 'lambdastr' function will return the python expression for the function.   Can be useful for transferring the function to another script.

```python
from sympy.utilities.lambdify import lambdastr
lambdastr([a,b],e)
```

    'lambda a,b: (2*a*b)'

```python

```