# Characteristics of Autonomous Market Makers

Date: 2021-03-26

(Date started: December 2020 Christmas holidays)

Author: [@mattmcd](https://twitter.com/mattmcd)

This notebook describes an approach using [method of characteristics](https://en.wikipedia.org/wiki/Method_of_characteristics) solutions of partial differential equations (PDEs) to examine existing AMM invariants such as Uniswap, Balancer and Curve (a.k.a. Stableswap).

```python
from IPython.display import HTML
# Hide code cells https://gist.github.com/uolter/970adfedf44962b47d32347d262fe9be
def hide_code():
    return HTML('''
    The raw code for this IPython notebook is by default hidden for easier reading.
    To toggle on/off the raw code, click <a href="javascript:code_toggle()">here</a>.''')
```

```python
import sympy as sp
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns

%matplotlib inline
```

```python
hide_code()
```

    The raw code for this IPython notebook is by default hidden for easier reading.
    To toggle on/off the raw code, click here.

From the [Balancer whitepaper](https://balancer.finance/whitepaper/):

> The bedrock of Balancer’s exchange functions is a surface defined by constraining a value function $V$
 — a function of the pool’s weights and balances — to a constant. We will prove that this surface implies a spot price at each point such that, no matter what exchanges are carried out, the share of value of each token in the pool remains constant.

The [Balancer whitepaper](https://balancer.finance/whitepaper/) shows that the value function
$$V = \sum_{i=1}^{n} x_{i}^{w_{i}}$$
is related to the token spot prices by the ratio of partial derivative.

# Starting from the Constraints
The idea of constant level sets of a value function creating constraints on system state (including prices) is discussed in ['From Curved Bonding to Configuration Spaces'](https://epub.wu.ac.at/7385/) by Zargham, Shorish, and Paruch (2020).

The existing Balancer value function implicit state constraint is 'the share of value of each token in the pool remains constant'.

In this section we look at starting from a set of constraints and see if it is possible to derive the corresponding value function.  We can then use this value function to determine allowed state changes e.g. for a swap the number of output tokens for an initial state and given number of input tokens.

This approach feels familiar to the Lagrangian dynamics approach in classical physics (the author's background).  In economics the standard approach seems to be start from a value function (a.k.a. utility function) and derive substitution functions that give prices.  Here we attempt to solve the inverse problem.

As a starting point, we consider deriving the Balancer value function (a [Cobb-Douglas Utility Function](https://www.wikiwand.com/en/Cobb%E2%80%93Douglas_production_function)) from the set of constraints for swaps 'the share of value of each token in the pool remains constant'.

We consider below two and three asset pools with tokens $X, Y, Z$.  The state of the system can be defined by three token balances $x$, $y$, $z$, and three weights $w_x$, $w_y$, $w_z$.

## Uniswap
The two asset case is a generalized form of [Uniswap](https://uniswap.org/docs/v2/protocol-overview/how-uniswap-works/) having token weights $w_x$ and $w_y$ summing to 1.  Uniswap uses $w_x = w_y = \frac{1}{2}$.  Here we use $x$ and $y$ to represent the token X and token Y balances.

The total value of the pool in tems of token X is
$$v_{x}(x,y) = x + p_{x}^{y}y = x - \frac{\partial{x}}{\partial{y}}y$$
i.e. number of tokens X plus the spot price of converting Y tokens into X tokens.

The constant share of value constraint is hence represented by the equations:
$$w_{x} = \frac{x}{x - \frac{\partial{x}}{\partial{y}}y} \\
w_y = \frac{- \frac{\partial{x}}{\partial{y}}y}{x - \frac{\partial{x}}{\partial{y}}y}$$
where in the two asset case the second equation is redundant since the weights sum to 1.

```python
x, y, w_x, w_y = sp.symbols('x y w_x w_y', positive=True)
k = sp.symbols('k', real=True)
X, Y = map(sp.Function, 'XY')
V = sp.Function('V')
```

We can specify that swaps happen on some invariant surface $V(x,y)$ which allows us to replace the spot price $-\frac{\partial{x}}{\partial{y}}$ in  $x - y\frac{\partial{x}}{\partial{y}} = w_x$, subsitituting $-\frac{\partial{x}}{\partial{y}} = \frac{\partial{V}}{\partial{y}}/\frac{\partial{V}}{\partial{x}}$ via the [implicit function theorem](https://en.wikipedia.org/wiki/Implicit_function_theorem).

```python
sp.Eq(x/(x + y*V(x,y).diff(y)/V(x,y).diff(x)), w_x)
```

$\displaystyle \frac{x}{x + \frac{y \frac{\partial}{\partial y} V{\left(x,y \right)}}{\frac{\partial}{\partial x} V{\left(x,y \right)}}} = w_{x}$

SymPy's PDE solver balks at this equation as written, so multiply through to make things easier.

```python
const_share_eq = sp.Eq(x*V(x,y).diff(x), w_x*(x*V(x,y).diff(x) + y*V(x,y).diff(y)))
```

```python
const_share_eq
```

$\displaystyle x \frac{\partial}{\partial x} V{\left(x,y \right)} = w_{x} \left(x \frac{\partial}{\partial x} V{\left(x,y \right)} + y \frac{\partial}{\partial y} V{\left(x,y \right)}\right)$

It turns out that SymPy is capable of solving the PDE directly to give a general solution.

```python
V_sol = sp.pdsolve(const_share_eq).subs({(1-w_x): w_y})
```

```python
V_sol
```

$\displaystyle V{\left(x,y \right)} = F{\left(- \frac{\log{\left(x^{- w_{x}} y^{- w_{y}} \right)}}{w_{y}} \right)}$

We can simplify the solution:

```python
sp.Eq(V(x,y), V_sol.rhs.simplify())
```

$\displaystyle V{\left(x,y \right)} = F{\left(\frac{w_{x} \log{\left(x \right)}}{w_{y}} + \log{\left(y \right)} \right)}$

We show below that the spot price is as expected regardless of the exact form of $F$ and if a specific form is chosen we achieve the desired Cobb-Douglas form.

```python
sp.Eq((V(x,y).diff(y)/V(x,y).diff(x)), V_sol.rhs.diff(y)/V_sol.rhs.diff(x))
```

$\displaystyle \frac{\frac{\partial}{\partial y} V{\left(x,y \right)}}{\frac{\partial}{\partial x} V{\left(x,y \right)}} = \frac{w_{y} x}{w_{x} y}$

By taking the exponential of the constant we obtain the general Uniswap invariant.

```python
sp.Eq(V(x,y), sp.exp(w_y*V_sol.rhs.args[0]).simplify())
```

$\displaystyle V{\left(x,y \right)} = x^{w_{x}} y^{w_{y}}$

Interestingly, the general solution offers the opportunity to use different functional forms to achieve the same constant share of value contraint.

```python
sp.Eq(V(x,y), (w_y*V_sol.rhs.args[0]).expand())
```

$\displaystyle V{\left(x,y \right)} = w_{x} \log{\left(x \right)} + w_{y} \log{\left(y \right)}$

We could derive the swap formulae for each form.  Would the formulae from log form of invariant be easier to implement on the Ethererum blockchain?  This is left as an exercise for the reader.

## Balancer
We now consider the general case of a Balancer pool with $n$ assets and weights summing to 1.  Here we examine the three asset case and identify the geometric constraints imposed by the share of value conditions
$$
w_x = \frac{x}{x - \frac{\partial{x}}{\partial{y}}y - \frac{\partial{x}}{\partial{z}}z}\\
w_y = \frac{-\frac{\partial{x}}{\partial{y}}y}{x - \frac{\partial{x}}{\partial{y}}y - \frac{\partial{x}}{\partial{z}}z}\\
w_z = \frac{-\frac{\partial{x}}{\partial{z}}z}{x - \frac{\partial{x}}{\partial{y}}y - \frac{\partial{x}}{\partial{z}}z}
$$
for tokens X, Y and Z having total value $v_x(x,y,z) = x + p_{x}^{y}y + p_{x}^{z}z = x - \frac{\partial{x}}{\partial{y}}y - \frac{\partial{x}}{\partial{z}}z$.

As before we can replace the spot prices given by the partial derivatives with expressions for the invariant surface V.  Below we show the $w_x$ condition, the others are similar.  The weight sum to 1 condition again allows us to eliminate one of the constraint equations.

```python
x, y, z, w_x, w_y, w_z = sp.symbols('x y z w_x w_y w_z', positive=True)
u, v, w = sp.symbols('u v w', positive=True)
xi = sp.Function('xi')
eta = sp.Function('eta')
zeta = sp.Function('zeta')
V = sp.Function('V')
V_d = sp.Function('\mathcal{V}')
```

```python
const_share_eq_3_1 = sp.Eq(x*V(x,y,z).diff(x), w_x*(x*V(x,y,z).diff(x) + y*V(x,y,z).diff(y) + z*V(x,y,z).diff(z)))
```

```python
const_share_eq_3_1
```

$\displaystyle x \frac{\partial}{\partial x} V{\left(x,y,z \right)} = w_{x} \left(x \frac{\partial}{\partial x} V{\left(x,y,z \right)} + y \frac{\partial}{\partial y} V{\left(x,y,z \right)} + z \frac{\partial}{\partial z} V{\left(x,y,z \right)}\right)$

We can simplify this to a constant coefficient first order PDE by the change of variables
$$
u = \xi(x) = \log{x}\\
v = \eta(y) = \log{y}\\
w = \zeta(z) = \log{z}
$$

The general form for the the change of variables is given by

```python
const_share_eq_3_1.subs({
    V(x,y,z): V(xi(x),eta(y),zeta(z)),
}).simplify().subs({V(xi(x),eta(y),zeta(z)): V_d })
```

$\displaystyle x \frac{d}{d \xi{\left(x \right)}} \mathcal{V} \frac{d}{d x} \xi{\left(x \right)} = w_{x} \left(x \frac{d}{d \xi{\left(x \right)}} \mathcal{V} \frac{d}{d x} \xi{\left(x \right)} + y \frac{d}{d \eta{\left(y \right)}} \mathcal{V} \frac{d}{d y} \eta{\left(y \right)} + z \frac{d}{d \zeta{\left(z \right)}} \mathcal{V} \frac{d}{d z} \zeta{\left(z \right)}\right)$

where $\mathcal{V} = V(\xi(x), \eta(y), \zeta(z))$

Substituting the logarithmic functional form for the transformed variables gives

```python
const_share_eq_3_1.subs({
    V(x,y,z): V(xi(x),eta(y),zeta(z)),
    xi(x): sp.log(x),
    eta(y): sp.log(y),
    zeta(z): sp.log(z)
}).simplify()
```

$\displaystyle \left. \frac{\partial}{\partial \xi_{1}} V{\left(\xi_{1},\log{\left(y \right)},\log{\left(z \right)} \right)} \right|_{\substack{ \xi_{1}=\log{\left(x \right)} }} = w_{x} \left(\left. \frac{\partial}{\partial \xi_{1}} V{\left(\xi_{1},\log{\left(y \right)},\log{\left(z \right)} \right)} \right|_{\substack{ \xi_{1}=\log{\left(x \right)} }} + \left. \frac{\partial}{\partial \xi_{2}} V{\left(\log{\left(x \right)},\xi_{2},\log{\left(z \right)} \right)} \right|_{\substack{ \xi_{2}=\log{\left(y \right)} }} + \left. \frac{\partial}{\partial \xi_{3}} V{\left(\log{\left(x \right)},\log{\left(y \right)},\xi_{3} \right)} \right|_{\substack{ \xi_{3}=\log{\left(z \right)} }}\right)$

The share of value weight constraints can written as a matrix equation:

```python
balancer_constraints = sp.ImmutableMatrix([
    [1-w_x, -w_x, -w_x],
    [-w_y, 1-w_y, -w_y],
    [-w_z, -w_z, 1-w_z]
])
v_grad = sp.ImmutableMatrix([V(u,v,w).diff(i) for i in [u,v,w]])
```

```python
sp.Eq(sp.MatMul(balancer_constraints, sp.UnevaluatedExpr(v_grad), evaluate=False).subs({V(u,v,w): V_d }),
      (balancer_constraints*v_grad).subs({V(u,v,w): V_d }),
     evaluate=False)
```

$\displaystyle \left[\begin{matrix}1 - w_{x} & - w_{x} & - w_{x}\\- w_{y} & 1 - w_{y} & - w_{y}\\- w_{z} & - w_{z} & 1 - w_{z}\end{matrix}\right] \left[\begin{matrix}\frac{d}{d u} \mathcal{V}\\\frac{d}{d v} \mathcal{V}\\\frac{d}{d w} \mathcal{V}\end{matrix}\right] = \left[\begin{matrix}- w_{x} \frac{d}{d v} \mathcal{V} - w_{x} \frac{d}{d w} \mathcal{V} + \left(1 - w_{x}\right) \frac{d}{d u} \mathcal{V}\\- w_{y} \frac{d}{d u} \mathcal{V} - w_{y} \frac{d}{d w} \mathcal{V} + \left(1 - w_{y}\right) \frac{d}{d v} \mathcal{V}\\- w_{z} \frac{d}{d u} \mathcal{V} - w_{z} \frac{d}{d v} \mathcal{V} + \left(1 - w_{z}\right) \frac{d}{d w} \mathcal{V}\end{matrix}\right]$

We can use the weights summing to 1 condition to eliminate one of these equations.  The nullspace of the constraint matrix then defines a plane of constant value of the invariant in $u,v,w$ space which we can then map back to the original X, Y, Z token balances.

```python
(sp.simplify(sp.ImmutableMatrix([
#     [1-w_x, -w_x, -w_x],
    [-w_y, 1-w_y, -w_y],
    [-w_z, -w_z, 1-w_z]
]).nullspace()[0]).subs({(1-w_y-w_z): w_x})*w_z
).T*sp.ImmutableMatrix([sp.log(x), sp.log(y), sp.log(z)])
```

$\displaystyle \left[\begin{matrix}w_{x} \log{\left(x \right)} + w_{y} \log{\left(y \right)} + w_{z} \log{\left(z \right)}\end{matrix}\right]$

It can be seen by inspection that exponentiating this invariant results in the original Balancer form.  As for the two asset case it is also possible to retain the value function in this form and derive new forms for the trading functions, which is again an exercise left to the reader.

## Curve
Curve uses the [StableSwap](https://www.curve.fi/stableswap-paper.pdf) invariant to reduce slippage for stablecoins all having equivalent value.  For example a pool could consist of USDC, USDT, BUSD and DAI, all of which are designed to track USD.  Pools tracking other assets are possible e.g. a BTC pool backed by sBTC, renBTC, wBTC.  The market maker token pool ideally consists of a balanced mix of each token type.  We consider a two asset pool below, this analysis extends to an arbitrary number of assets.

The StableSwap invariant is designed to act as a constant sum market maker $x+y=1$ for small imbalances, and a constant product Uniswap market maker $xy=k$ as the pool becomes more imbalanced.  These are the price constraints defining the system i.e.

* at small imbalance $-\frac{\partial{x}}{\partial{y}}=1$ and tokens are freely interchangeable
* at larger imbalance $-\frac{\partial{x}}{\partial{y}}=x/y$ as for Uniswap (or in general an equal weight Balancer pool)

The StableSwap invariant can be written as $V{\left(x,y \right)} = s \left(x + y\right) + x^{w_{x}} y^{w_{y}}$ where $s$ is an amplification parameter that determines the transition between constant sum and constant product behaviour.

```python
s = sp.symbols('s', positive=True)
```

```python
V_ss = s*(x+y) + x**w_x*y**w_y
```

```python
sp.Eq(V(x,y), V_ss)
```

$\displaystyle V{\left(x,y \right)} = s \left(x + y\right) + x^{w_{x}} y^{w_{y}}$

Below we show the spot price of Y tokens in terms of X tokens that results from this invariant function.  We can see that the limit $s \rightarrow \infty$ gives the constant sum behaviour while the $s \rightarrow 0$ limit gives constant product behaviour.

```python
V_x = (V_ss).diff(x).subs({w_x: sp.Rational(1,2), w_y: sp.Rational(1,2)}).simplify()
V_y = (V_ss).diff(y).subs({w_x: sp.Rational(1,2), w_y: sp.Rational(1,2)}).simplify()
ss_spot = V_y/V_x.simplify()
ss_spot_eq = sp.Eq(V(x,y).diff(y)/V(x,y).diff(x), ss_spot)
ss_spot_eq
```

$\displaystyle \frac{\frac{\partial}{\partial y} V{\left(x,y \right)}}{\frac{\partial}{\partial x} V{\left(x,y \right)}} = \frac{s + \frac{\sqrt{x}}{2 \sqrt{y}}}{s + \frac{\sqrt{y}}{2 \sqrt{x}}}$

```python
sp.Eq(sp.Limit(ss_spot, s, sp.oo), (ss_spot).limit(s, sp.oo))
```

$\displaystyle \lim_{s \to \infty}\left(\frac{s + \frac{\sqrt{x}}{2 \sqrt{y}}}{s + \frac{\sqrt{y}}{2 \sqrt{x}}}\right) = 1$

```python
sp.Eq(sp.Limit(ss_spot, s, 0), (ss_spot).limit(s, 0))
```

$\displaystyle \lim_{s \to 0^+}\left(\frac{s + \frac{\sqrt{x}}{2 \sqrt{y}}}{s + \frac{\sqrt{y}}{2 \sqrt{x}}}\right) = \frac{x}{y}$

Following the previous procedure we'd hope to be able to solve the PDE for $V(x,y)$ from the spot price constraint:
$$\left(s + \frac{\sqrt{y}}{2 \sqrt{x}}\right) \frac{\partial}{\partial y} V{\left(x,y \right)} = \left(s + \frac{\sqrt{x}}{2 \sqrt{y}}\right) \frac{\partial}{\partial x} V{\left(x,y \right)}$$
Attempting this with the actual StableSwap spot price above doesn't immediately work although it should be possible to solve numerically.

```python
ss_spot_denom = sp.denom(ss_spot_eq.rhs)* sp.denom(ss_spot_eq.lhs)
sp.Eq(ss_spot_eq.lhs * ss_spot_denom, ss_spot_eq.rhs * ss_spot_denom)
```

$\displaystyle \left(s + \frac{\sqrt{y}}{2 \sqrt{x}}\right) \frac{\partial}{\partial y} V{\left(x,y \right)} = \left(s + \frac{\sqrt{x}}{2 \sqrt{y}}\right) \frac{\partial}{\partial x} V{\left(x,y \right)}$

## A new Curve
We can look at the form of the PDE from the StableSwap constraint and explore related functional forms.  It looks like the same limiting behaviour could be achieved with any ratio of $x/y$ i.e. without requiring the $\surd$.  We hence try $$\left(s x y + y\right) \frac{\partial}{\partial y} V{\left(x,y \right)} = \left(s x y + x\right) \frac{\partial}{\partial x} V{\left(x,y \right)}$$
which is easily solvable by SymPy and results in a new Curve invariant:

```python
sol2 = sp.pdsolve((s*x*y + y)*V(x,y).diff(y) - (s*x*y + x)*V(x,y).diff(x), V(x,y)).rhs
V_ss_new = sp.log(sol2.args[0]).expand()
sp.Eq(V(x,y),V_ss_new)
```

$\displaystyle V{\left(x,y \right)} = s x + s y + \log{\left(x \right)} + \log{\left(y \right)}$

We see that the spot price has the same desired limiting behaviour:

```python
ss_spot_new = (V_ss_new.diff(y)/V_ss_new.diff(x)).simplify()
```

```python
ss_spot_new
```

$\displaystyle \frac{x \left(s y + 1\right)}{y \left(s x + 1\right)}$

```python
sp.Eq(sp.Limit(ss_spot_new, s, sp.oo), (ss_spot_new).limit(s, sp.oo))
```

$\displaystyle \lim_{s \to \infty}\left(\frac{x \left(s y + 1\right)}{y \left(s x + 1\right)}\right) = 1$

```python
sp.Eq(sp.Limit(ss_spot_new, s, 0), (ss_spot_new).limit(s, 0))
```

$\displaystyle \lim_{s \to 0^+}\left(\frac{x \left(s y + 1\right)}{y \left(s x + 1\right)}\right) = \frac{x}{y}$

Comparing to the characteristic surfaces obtained for Uniswap and Balancer using this approach, we see that the functional form is quite similar i.e. a plane in $\log{x}-\log{y}$ space now with the addition of an exponential surface for the imbalance part.

A nice feature of this new form is the possibility of investigating weighted Curve pools.

# Conclusion
This notebook has demonstrated using the method of characteristics to solve the inverse problem in autonomous market maker design i.e. deriving the invariant function that satisfies some constraints.

Some possibly original results for alternative Balancer and Curve invariants are presented.

It is hoped that the wider DeFi community finds this technique and the use of a computer algebra system such as [SymPy](https://www.sympy.org/en/index.html) useful.  It offers a complementary approach to simulation based methods such as [cadCAD](https://twitter.com/cadcad_org).

# References and Related Work

## DeFi
The DeFi space moves quite rapidly so as well as the references below also see Medium for Balancer, Uniswap, Curve etc.  Also [The Defiant](https://thedefiant.substack.com/) newsletter to keep up with things.

[Balancer whitepaper](https://balancer.finance/whitepaper/) by [@fcmartinelli](https://twitter.com/fcmartinelli).

[From Curved Bonding to Configuration Spaces](https://epub.wu.ac.at/7385/) by [@mZargham](https://twitter.com/mZargham)

Lots of papers by [@alexhevans](https://twitter.com/alexhevans), [@tarunchitra](https://twitter.com/tarunchitra) and [@GuilleAngeris](https://twitter.com/GuilleAngeris).  A couple of highly relevant entry points below, which annoyingly the author only got around to reading _after_ doing most of the work in this notebook.
* [When does the tail wag the dog? Curvature and Market Making](https://arxiv.org/abs/2012.08040)
* [Liquidity Provider Returns in Geometric Mean Markets](https://arxiv.org/abs/2006.08806)
* [Optimal Fees for Geometric Mean Market Makers](https://stanford.edu/~guillean/papers/g3m-optimal-fee.pdf)

## Mathematics
The author first encountered the method of characterstics last millenium during their undergraduate degree at University of Western Australia.  "Partial Differential Equations: An Introduction" by Strauss was the text but any reasonably advanced undergraduate engineering mathematics book should have something relevant.

Comparing the Balancer or Uniswap invariants to the Cobb-Douglas utility or production function isn't something the author has seen that widely and needs to learn more microeconomics to see how it could fit in.  Seems related to  [Optimization Methods in Finance](https://www.cambridge.org/core/books/optimization-methods-in-finance/8A4996C5DB2006224E4D983B5BC95E3B) and the [Production Possibility Frontier](https://www.wikiwand.com/en/Production%E2%80%93possibility_frontier) e.g. this [handout by Kyle Woodward](https://kylewoodward.com/blog_data/pdfs/handout_micro_equilibrium.pdf).

```python

```


# 2 Cross Validation (NumPy)

## 2.1 Utility Functions
Rewrite the code in the previous tutorial as functions, and expand them to *deg* degree with *deg+1* terms.
- *polynomial_evaluate(x,w)* evaluates the polynomial function (defined by the weights $w$) at $x$ locations.
- *polynomial_fit(x,deg)* fits a new polynomial (defined by $w$) with a fixed (highest) degree *deg*, to the given data pairs [$x$, $t$].
- *polynomial_basis(x,deg)* constructs terms of polynomial function of given $x$ with a degree of *deg*.

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

# functions based on the previous tutorial
def polynomial_evaluate(x, w):
    X = polynomial_basis(x, w.size-1)
    t = np.matmul(X, w)
    return t

def polynomial_fit(x, t, deg):
    X = polynomial_basis(x, deg)
    w = np.linalg.lstsq(X, t, rcond=None)
    return w[0]

def polynomial_basis(x, deg):
    X = np.power(np.reshape(x,[-1,1]), np.linspace(deg,0,deg+1))
    return X

def rms_error(residuals):
    return np.sqrt(np.mean(np.square(residuals)))

# test
X = polynomial_basis(np.array([.1,.2,.3,.4,.5]), 3)
print(X)
```

    [[0.001 0.01  0.1   1.   ]
     [0.008 0.04  0.2   1.   ]
     [0.027 0.09  0.3   1.   ]
     [0.064 0.16  0.4   1.   ]
     [0.125 0.25  0.5   1.   ]]

## 2.2 Data
In this tutorial, the true model here is a polynomial with a degree of 4:

\begin{equation}
t = w_3x^3 + w_2x^2 + w_1x +w_0
\end{equation}

with weights $$\textbf{w}=\begin{vmatrix}4 & 3 & 2 & 1\end{vmatrix}^T$$

*n* observed data are sampled as follows:

```python
# model
w = np.reshape([4, 3, 2, 1], [-1, 1])

# sample locations
n = 50
x = np.linspace(-1,1,n)

# observed target data
std_noise = 0.5
t = polynomial_evaluate(x, w)
t_observed = t + np.random.normal(0,std_noise,[n,1])

# plot
plt.plot(x,t,'r')
plt.plot(x,t_observed,'bo')
```

Additionally, sample *n_holdout* holdout data that will not be used to fit the model:

```python
# holdout (also observed) data
n_holdout = 30
x_holdout = np.linspace(-1,1,n_holdout)
t_holdout = polynomial_evaluate(x_holdout, w) + np.random.normal(0,std_noise,[n_holdout,1])
```

## 2.3 Underfitting and Overfitting
First, try to fit the following polynomial models, the first of which is a straight line:

\begin{equation}
t_{deg=1} = w_1x + w_0
\end{equation}

\begin{equation}
t_{deg=2} = w_2x^2 + w_1x + w_0
\end{equation}

\begin{equation}
t_{deg=3} = w_3x^3 + w_2x^2 + w_1x + w_0
\end{equation}

$$\cdots$$

\begin{equation}
t_{deg=m} = w_mx^m + \cdots + w_3x^3 + w_2x^2 + w_1x + w_0
\end{equation}

```python
# first, fit the polynomials
degrees = [1,2,3,4,5,10,20,30]  # examples of degrees
w_estimates = []
t_estimates = []
for idx in range(len(degrees)):
    w_estimates.append(polynomial_fit(x, t_observed, deg=degrees[idx]))
    t_estimates.append(polynomial_evaluate(x, w_estimates[idx]))

    # residuals and root-mean-square errors
    Residuals = t_estimates[idx]-t_observed
    SR = np.sum(np.square(Residuals))
    RMSE = rms_error(Residuals)
    print([degrees[idx],SR,RMSE])
```

    [1, 74.72943695405607, 1.222533737399963]
    [2, 40.7632977733088, 0.902920791357789]
    [3, 13.020498727665302, 0.5103038061324902]
    [4, 12.778657505365585, 0.5055424315597176]
    [5, 12.475289292186131, 0.49950554135437036]
    [10, 10.790196004501091, 0.4645470052535285]
    [20, 7.5908306149299065, 0.38963651304593894]
    [30, 5.256043026763938, 0.32422347314048494]

Now, plot each fitted curve to compare, for example:

```python
plt.figure()
idx = degrees.index(1)
plt.plot(x,t,'r')
plt.plot(x,t_observed,'bo')
plt.plot(x,t_estimates[idx],'g')

plt.figure()
idx = degrees.index(30)
plt.plot(x,t,'r')
plt.plot(x,t_observed,'bo')
plt.plot(x,t_estimates[idx],'g')
```

## 2.3 K-fold Cross-validation
K-fold cross-validation splits the original sample into k equal-sized subsamples. Of the k subsamples, a single subsample is retained as the validation data for testing the model, and the remaining k − 1 subsamples are used as training data. The training-testing process is then repeated k times, with each of the k subsamples used exactly once as the validation data. The k results can then be averaged to produce an estimation of the model performance.

It is of great importance to understand the difference between training- and testing errors. Conceptually, the training error is to summarise the residuals between the fitted-model predicted targets and the observed ones which have been used for fitting the model, whilst the testing error is the difference based on an independent set of data that have been *unseen* to the model fitting process, which estimates the optimal model weights. The concept of "unseen" is also useful in distinguishing different types of errors in cross-validation experiments.

```python
import random

# shuffle data first
indices = [idx for idx in range(n)]
random.seed(1)
random.shuffle(indices)

# set up a 5-fold cross-validation
k = 5
size_fold = int(n/k)
deg = 4  # model to fit

Residuals_train = []
Residuals_test = []
for idx_fold in range(k):  # fold index
    # set indices for training data and test data
    indices_test = indices[idx_fold*size_fold:(idx_fold+1)*size_fold]
    indices_train = list(set(indices) - set(indices_test))

    # training
    w_train = polynomial_fit(x[indices_train], t_observed[indices_train], deg=deg)
    t_train = polynomial_evaluate(x[indices_train], w_train)
    Residuals_train.append(t_train-t_observed[indices_train])
    # testing
    t_test = polynomial_evaluate(x[indices_test], w_train)
    Residuals_test.append(t_test-t_observed[indices_test])

# calculating training and testing errors
RMSE_train = rms_error(np.concatenate(Residuals_train, axis=0))
RMSE_test = rms_error(np.concatenate(Residuals_test, axis=0))
print(RMSE_train)
print(RMSE_test)
```

    0.4976593139510383
    0.5734202763560762

## Questions
- Find an optimal degree with respect to the testing error.
- What can reduce training error and/or testing error?
- Given a fixed set of observed data, is it possible to find a polynomial that produces smaller testing error than the underlying "true" model?
- What is the difference between an overfitted polynomial model and an overfitted cross-validation model?

## 2.4 Generalisability
Different from the testing error obtained in each fold, a better estimate of the generalisation ability can be quantified by the testing error on holdout data.

```python
# build the model using all the data and the selected (optimised) hyperparameter degree
deg = 4
w_train = polynomial_fit(x, t_observed, deg=deg)
t_train = polynomial_evaluate(x, w_train)
RMSE_train = rms_error(t_train-t_observed)
t_test = polynomial_evaluate(x_holdout, w_train)
RMSE_test = rms_error(t_test-t_holdout)
print(RMSE_train)
print(RMSE_test)
```

    0.5055424315597176
    0.5398329824140627

## Questions
- Adding norm-2 regularisation to the model fitting, does this help?
- What is the required data size for training and testing. How are training and testing errors affected by the data size?
- Compare testing errors in cross-validation and those based on holdout data. Under what conditions, they are different?


# Music Machine Learning - Probability distributions

### Author: Philippe Esling (esling@ircam.fr)

In this course we will cover
1. A [quick recap](#recap) on simple probability concepts
2. An introduction to [probability distributions](#distributions)
3. An explanation on how to [sample](#sampling) from distributions ourselves

## Quick recap on probability

The field of probability aims to model random or uncertain events through random variables $X$ denoting a quantity that is uncertain, which take values $\omega$ in a sample space $\omega \in \Omega$. If we observe several occurrences of this variable $\{\mathbf{x}_{i}\}_{i=1}$, we might try to model the _probability distribution_ $p(\mathbf{x})$ of that variable. We recall here that the probability of an event $a$ is a real number $p(a)$, with $0 \leq p(a) \leq 1$, knowing that $p(\Omega)=1$ and $p(\{\})=0$. The probability of two events occuring simultaneously is defined as $p\left(a \cup b \right)$. Therefore, the probability of one event **or** the other occuring is defined as
$
\begin{equation}
p\left(a \cap b \right) = p(a) + p(b) - p\left(a \cup b \right)
\end{equation}
$

The *conditional probability* of an event $a$ occuring *given* another event $b$ is denoted $p \left(a \mid b \right)$ and is defined as
$$
\begin{equation}
p \left(a \mid b \right) = \frac{p \left(a , b \right)}{p \left(b \right)}
\end{equation}
$$

This can be understood as the probability of event $a$ to occur if we restrict the world of possibilities to event $b$. The *chain rule* defines the probabilities of a set of events to co-occur simultaneously
$$
\begin{equation}
p \left(x_{1},...,x_{n} \right) = \prod_{i=n}^{1}{p \left(x_{i}\mid x_{i-1},..., x_{1} \right)}
\end{equation}
$$

Finally, we say that two events are independent if $p(a\mid b) = p(a)$.

To understand these concepts graphically, we will rely on `PyTorch` and specifically the `distributions` package.

```python
import torch.distributions as distribution
import torch.distributions.transforms as transform
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
from helper_plot import hdr_plot_style
hdr_plot_style()
```

### Probability distributions

Let $X$ be a random variable associated with a *probability distribution function* that assigns probabilities to the different outcomes $X$ can take in the sample space. We can divide random variables into three different types:

- **$X$ is discrete**: Discrete random variables may only assume values on a specified list.
- **$X$ is continuous**: Continuous random variable can take on arbitrarily exact values.
- **$X$ is mixed**: Mixed random variables assign probabilities to both discrete and continuous random variables, (i.e. a combination of the above two categories).

**Expected Value**
The expected value $\mathbb{E}\left[X\right]$ for a given probability distribution can be described as _"the mean expected value for many repeated samples from that distribution."_ As the number of repeated observation goes to infinity, the difference between the average outcome and the expected value becomes arbitrarily small. Formally, it is defined for discrete variables as

$$
\mathbb{E}\left[X\right] = \sum\limits_{i}\bx_i p(\bx=\bx_i)
$$

#### Discrete distributions

If $X$ is discrete, then its distribution is called a *probability mass function* (pmf), which measures the probability $X$ takes on the value $x_{i}$, denoted $P(X=x_{i})$. Let $\mathbf{x}$ be a discrete random variable with range $R_{X}=\{x_1,\cdots,x_n\}$ (finite or countably infinite). The function

$$
p_{X}(x_{i})=p(X=x_{i}), \forall i\in\{1,\cdots,n\}
$$

is called the probability mass function (PMF) of $X$.

Hence, the PMF defines the probabilities of all possible values for a random variable. The above notation allows to express that the PMF is defined for the random variable $X$, so that $p_{X}(1)$ gives the probability that $X=1$. For discrete random variables, the PMF is also called the \textit{probability distribution}. The PMF is a probability measure, therefore it satisfies all the corresponding properties
- $0 \leq p_{X}(x_i) < 1, \forall x_i$
- $\sum_{x_i\in R_{X}} p_{X}(x_i) = 1$
- $\forall A \subset R_{X}, p(X \in A)=\sum_{x_a \in A}p_{X}(x_a)$

A very simple example of discrete distribution is the `Bernoulli` distribution. With this distribution, _we can model a coin flip_, if it has equal probability. More formally, a Bernoulli distribution is defined as

$$
Bernoulli(x)= p^x (1-p)^{(1-x)}
$$

with $p$ controlling the probability of the two classes. Hence, a fair coin should have $p=0.5$, and if we throw the coin a very large number of times, we hope to see on average an equal amount of _heads_ and _tails_.

```python
bernoulli = distribution.Bernoulli(0.5)
samples = bernoulli.sample((10000,))
plt.figure(figsize=(10,8))
sns.distplot(samples)
plt.title("Samples from a Bernoulli (coin toss)")
plt.show()
```

However, we can also _sample_ from the distribution to have individual values of a single throw. In that case, we obtain a series of separate events that _follow_ the distribution

```python
vals = ['heads', 'tails']
samples = bernoulli.sample((10, ))
for s in samples:
    print('Coin is tossed on ' + vals[int(s)])
```

Now, we can mess up our probability to model an unfair (loaded) coin, as shown in the following example (where we use a cheated coin that should give us a lot more of _heads_ than _tails_)

```python
bernoulli = distribution.Bernoulli(0.3)
samples = bernoulli.sample((10000,))
plt.figure(figsize=(10, 8)); sns.distplot(samples)
plt.title("Samples from an unfair coin"); plt.show()
vals = ['heads', 'tails']
samples = bernoulli.sample((10, ))
for s in samples:
    print('Coin is tossed on ' + vals[int(s)])
```

#### Poisson distribution

Let's introduce one of the (many) useful probability mass functions. We say $Z$ is *Poisson*-distributed if:

$$
\begin{equation}
P\left(Z = k\right) =\frac{ \lambda^k e^{-\lambda} }{k!}, \; \; k \in \mathbb{N^{+}}
\end{equation}
$$

$\lambda \in \mathbb{R}$ is a parameter of the distribution that controls its shape (usually termed the *intensity* of the Poisson distribution). By increasing $\lambda$, we add more probability to larger values. One can describe $\lambda$ as the *intensity* of the Poisson distribution. If a random variable $Z$ has a Poisson mass distribution, we denote it by

$$
\begin{equation}
Z \sim \text{Poi}(\lambda)
\end{equation}
$$

One useful property of the Poisson distribution is that its expected value is equal to its parameter, i.e.:

$$E\large[ \;Z\; | \; \lambda \;\large] = \lambda $$

We will plot the probability mass distribution for different $\lambda$ values.

### Continuous distributions

The same ideas apply to _continuous_ random variables, which can model for instance the height of human beings. If we try to guess the height of someone that we do not know, there is a higher probability that this person will be around 1m70, instead of 20cm or 3m. For the rest of this course, we will use the shorthand notation $p(\mathbf{x})$ for the distribution $p(\mathbf{x}=x_{i})$,  which expresses for a real-valued random variable $\mathbf{x}$, evaluated at $x_{i}$, the probability that $\mathbf{x}$ takes the value $x_i$.

One notorious example of such distributions is the Gaussian (or Normal) distribution, which is defined as
\begin{equation}
    p(x)=\mathcal{N}(\mu,\sigma)=\frac{1}{\sqrt{2\pi\sigma^{2}}}e^{-\frac{(x-\mu)^{2}}{2\sigma^{2}}}
\end{equation}

Similarly as before, we can observe the behavior of this distribution with the following code (in our height example)

```python
normal = distribution.Normal(150., 30.)
samples = normal.sample((10000, ))
sns.distplot(samples)
plt.title("Samples from a standard Normal")
plt.show()
```

If we have access to this complete probability distribution (its exact parameterization and function), we can generate samples (in this case "new humans") that follow the correct distribution. You can experiment with this in the following code space.

```python

######################
# YOUR CODE GOES HERE
######################

```

An example of continuous random variable is a random variable with *exponential density*

$$
\begin{equation}
f_Z(z | \lambda) = \lambda e^{-\lambda z }, \;\; z\ge 0
\end{equation}
$$

When a random variable $Z$ has an exponential distribution with parameter $\lambda$, we say *$Z$ is exponential*

$$Z \sim \text{Exp}(\lambda)$$

Given a specific $\lambda$, the expected value of an exponential random variable is equal to the inverse of $\lambda$, that is

$$E[\; Z \;|\; \lambda \;] = \frac{1}{\lambda}$$

### PyTorch distributions

Here, we rely on the [PyTorch distributions module](https://pytorch.org/docs/stable/_modules/torch/distributions/), which is defined in `torch.distributions`. Most notably, we are going to rely both on the `Distribution` and `Transform` objects.

```python
# Imports for plotting
import numpy as np
import matplotlib.pyplot as plt
# Define grids of points (for later plots)
x = np.linspace(-4, 4, 1000)
z = np.array(np.meshgrid(x, x)).transpose(1, 2, 0)
z = np.reshape(z, [z.shape[0] * z.shape[1], -1])
```

Inside this toolbox, we can already find some of the major probability distributions that we are used to deal with

```python
p = distrib.Normal(loc=0, scale=1)
p = distrib.Bernoulli(probs=torch.tensor([0.5]))
p = distrib.Beta(concentration1=torch.tensor([0.5]), concentration0=torch.tensor([0.5]))
p = distrib.Gamma(concentration=torch.tensor([1.0]), rate=torch.tensor([1.0]))
p = distrib.Pareto(alpha=torch.tensor([1.0]), scale=torch.tensor([1.0]))
```

The interesting aspect of these `Distribution` objects is that we can both obtain some samples from it through the `sample` (or `sample_n`) function, but we can also obtain the analytical density at any given point through the `log_prob` function

```python
# Based on a normal
n = distrib.Normal(0, 1)
# Obtain some samples
samples = n.sample((1000, ))
# Evaluate true density at given points
density = torch.exp(n.log_prob(torch.Tensor(x))).numpy()
# Plot both samples and density
fig, (ax1, ax2) = plt.subplots(1, 2, sharex=True, figsize=(15, 4))
ax1.hist(samples, 50, alpha=0.8);
ax1.set_title('Empirical samples', fontsize=18);
ax2.plot(x, density); ax2.fill_between(x, density, 0, alpha=0.5)
ax2.set_title('True density', fontsize=18);
```

Here you can experiment with different distributions, and try to compare how they behave depending on their parameters and also on how much _samples_ you draw from these.

```python

######################
# YOUR CODE GOES HERE
######################

```

## Sampling from distributions

The advantage of using probability distributions is that we can *sample* from these to obtain examples that follow the distribution. For instance, if we perform sampling repeatedly (up to infinity) from a Gaussian PDF, the different values will be distributed following the exact Gaussian distribution. However, although we know the PDF, we need to compute the *Cumulative Distribution Function* (CDF), and then its inverse to obtain the sampling function. Therefore, if we denote the PDF as $f_{X}(x)$, we need to compute the CDF

$$
\begin{equation}
F_{X}\left(x\right)=\intop_{\infty}^{x}f_{X}\left(t\right)dt
\end{equation}
$$

Then, the *inverse sampling method* consists in solving and applying the inverse CDF $F_{X}^{-1}\left(x\right)$. Here, we recall that the Exponential probability is defined with the following function.

$$
p_{\lambda}(x) = \lambda e^{-\lambda x}
$$

with $\lambda$ defining the _rate_ parameter. Therefore, to be able to define our own `sample` method, we need to solve for

$$
F^{-1}_Y(x) = \left(\int_0^x \lambda e^{-\lambda y} dy\right)^{-1}
$$

***

**Exercise**

1. Code the exponential probability density functions
2. Perform the inverse transform method on the exponential distribution PDF
3. Code the `sample_exponential` function to sample from the exponential
4. (optional) Perform the same operations for sampling from the Beta distribution

***

```python
from scipy.stats import expon
nb_samples = 500
nb_bins = 50

def sample_exponential(mu, n, m):

    ######################
    # YOUR CODE GOES HERE
    ######################

    ######################
    # Solution

    samples = np.random.uniform(0, 1, size=(m,n))
    samples = - np.log(1 - samples)
    samples *= mu

    ######################

    return samples

# Exponential distribution
mu = 2
samples = np.random.exponential(mu, nb_samples)
samples_ex = sample_exponential(mu, nb_samples, 1)
# Compute the PDF
X = np.linspace(0, np.max(samples), int(np.max(samples)) * 100)
y1 = expon.pdf(X) #* (nb_samples / nb_bins) * int(np.max(samples) * 1.5)
# Display both
plt.figure(figsize=(12, 8))
plt.subplot(2,1,1)
plt.hist(samples, 50, label='Samples')
plt.plot(X,y1,ls='--',c='r',linewidth=2, label='Exponential PDF')
plt.legend(loc=1)
plt.subplot(2,1,2)
plt.hist(samples_ex, 50, label='Samples')
plt.plot(X,y1,ls='--',c='r',linewidth=2, label='Our exponential sampples')
plt.legend(loc=1)
```

```python
def sampleBeta(a, b, M, N):

    ######################
    # YOUR CODE GOES HERE
    ######################

    ######################
    # Solution

    samples = np.random.beta(a, b, size=(M, N))

    ######################

    return samples

from scipy.stats import beta
# Beta distribution
a = 0.6
b = 1.5
samples = np.random.beta(a, b, nbSamples)
samplesBeta = sampleBeta(a, b, nbSamples, 1)
# Compute the PDF
X = np.linspace(0, 1, 100)
y1 = beta.pdf(X, a, b) * (nbSamples / nbBins) * (np.max(samples) * 1.5)
# Display both
plt.figure(figsize=(12, 8))
plt.subplot(2,1,1)
plt.hist(samples, 50, label='Samples')
plt.plot(X,y1,ls='--',c='r',linewidth=2, label='Beta PDF')
plt.legend(loc=1)
plt.subplot(2,1,2)
plt.hist(samplesBeta, 50, label='Samples')
plt.plot(X,y1,ls='--',c='r',linewidth=2, label='Beta PDF')
plt.legend(loc=1)
```

### Comparing distributions (KL divergence)
$
\newcommand{\R}{\mathbb{R}}
\newcommand{\bb}[1]{\mathbf{#1}}
\newcommand{\bx}{\bb{x}}
\newcommand{\by}{\bb{y}}
\newcommand{\bz}{\bb{z}}
\newcommand{\KL}[2]{\mathcal{D}_{\text{KL}}\left[#1 \| #2\right]}$
Originally defined in the field of information theory, the _Kullback-Leibler (KL) divergence_ (usually noted $\KL{p(\bx)}{q(\bx)}$) is a dissimilarity measure between two probability distributions $p(\bx)$ and $q(\bx)$. In the view of information theory, it can be understood as the cost in number of bits necessary for coding samples from $p(\bx)$ by using a code optimized for $q(\bx)$ rather than the code optimized for $p(\bx)$. In the view of probability theory, it represents the amount of information lost when we use $q(\bx)$ to approximate the true distribution $p(\bx)$. %that explicit the cost incurred if events were generated by $p(\bx)$ but charged under $q(\bx)$

Given two probability distributions $p(\bx)$ and $q(\bx)$, the Kullback-Leibler divergence of $q(\bx)$ _from_ $p(\bx)$ is defined to be
\begin{equation}
    \KL{p(\bx)}{q(\bx)}=\int_{\R} p(\bx) \log \frac{p(\bx)}{q(\bx)}d\bx
\end{equation}

Note that this dissimilarity measure is _asymmetric_, therefore, we have
\begin{equation}
    \KL{p(\bx)}{q(\bx)}\neq \KL{q(\bx)}{p(\bx)}
\end{equation}
This asymmetry also describes an interesting behavior of the KL divergence, depending on the order to which it is evaluated. The KL divergence can either be a _mode-seeking_ or _mode-coverage_ measure (we will come back to these notions in the _approximate inference_ course)

```python

```


# HW 3

## Imports

```python
import nbtools
nbtools.setup_nb()
```

```python
import itertools
import sympy
from sympy.diffgeom import Manifold, Patch
from pystein import coords, metric, curvature
from pystein.utilities import tensor_pow as tpow, full_simplify
```

## Problem 1

```python
M = Manifold('M', dim=3)
P = Patch('origin', M)
rho, z, phi = sympy.symbols('rho z phi', nonnegative=True)

psi = sympy.Function('psi')(rho, z, phi)
q = sympy.Function('q')(rho, z)
cs = coords.CoordSystem('cylindrical', P, [rho, z, phi])
drho, dz, dphi = cs.base_oneforms()

ds2 =  (sympy.exp(q) * (tpow(drho, 2) + tpow(dz, 2))
                  + rho ** 2 * tpow(dphi, 2))
gamma = metric.Metric(twoform=ds2)

gamma
```

$\displaystyle \rho^{2} \operatorname{d}\phi \otimes \operatorname{d}\phi + e^{q{\left(\rho,z \right)}} \left(\operatorname{d}\rho \otimes \operatorname{d}\rho + \operatorname{d}z \otimes \operatorname{d}z\right)$

```python
ricci = curvature.ricci_scalar(gamma).doit()
ricci = full_simplify(ricci)
ricci
```

$\displaystyle - \left(\frac{\partial^{2}}{\partial \rho^{2}} q{\left(\rho,z \right)} + \frac{\partial^{2}}{\partial z^{2}} q{\left(\rho,z \right)}\right) e^{- q{\left(\rho,z \right)}}$

```python
christoffels = [((i, i, k), curvature.christoffel_symbol_component(k, i, i, gamma).doit())
                for i, k in itertools.product(range(3), range(3))]
```

```python
curvature.display_components(christoffels)
```

$\begin{split} (\mu=0, \nu=0, \rho=0) &: \frac{\frac{\partial}{\partial \rho} q{\left(\rho,z \right)}}{2} \\ (\mu=0, \nu=0, \rho=1) &: - \frac{\frac{\partial}{\partial z} q{\left(\rho,z \right)}}{2} \\ (\mu=0, \nu=0, \rho=2) &: 0 \\ (\mu=1, \nu=1, \rho=0) &: - \frac{\frac{\partial}{\partial \rho} q{\left(\rho,z \right)}}{2} \\ (\mu=1, \nu=1, \rho=1) &: \frac{\frac{\partial}{\partial z} q{\left(\rho,z \right)}}{2} \\ (\mu=1, \nu=1, \rho=2) &: 0 \\ (\mu=2, \nu=2, \rho=0) &: - \rho e^{- q{\left(\rho,z \right)}} \\ (\mu=2, \nu=2, \rho=1) &: 0 \\ (\mu=2, \nu=2, \rho=2) &: 0 \end{split}$

LHS

```python
one_form = [sympy.Derivative(psi, cs.base_symbols()[k]) for k in range(3)]
```

```python
def cov_deriv_one_form(i, j, form, g, scale_by_inverse: bool = False):
    coord_syms = g.coord_system.base_symbols()
    v = sympy.Derivative(form[j], coord_syms[i])

    for k in range(len(coord_syms)):
        term = curvature.christoffel_symbol_component(k, i, j, g) * form[k]
        if scale_by_inverse:
            term *= g.matrix.inverse()[i, j]
        v -= term

    return full_simplify(v.doit())
```

```python
def Dbar2(i, j, conformal_func, g):
    one_form = [sympy.Derivative(psi, g.coord_system.base_symbols()[k]) for k in range(3)]
    raw = cov_deriv_one_form(i, j, one_form, g)
    return full_simplify(raw.doit())
```

```python
DiDjPsi = sympy.Matrix([[Dbar2(0, j, psi, gamma) for j in range(3)],
                            [Dbar2(1, j, psi, gamma) for j in range(3)],
                            [Dbar2(2, j, psi, gamma) for j in range(3)]])
```

```python
Dbar2Psi = sum([gamma.matrix.inverse()[i, j] * DiDjPsi[i, j]
                for i, j in itertools.product(range(3), range(3))])
```

```python
LHS = full_simplify(Dbar2Psi)
RHS = psi / 8 * ricci
```

```python
notation_subs = [
    (psi, sympy.symbols('psi'))
]
```

```python
LHS.subs(notation_subs)
```

$\displaystyle \frac{\left(\rho^{2} \left(\frac{d^{2}}{d \rho^{2}} \psi + \frac{d^{2}}{d z^{2}} \psi\right) + \rho \frac{d}{d \rho} \psi + e^{q{\left(\rho,z \right)}} \frac{d^{2}}{d \phi^{2}} \psi\right) e^{- q{\left(\rho,z \right)}}}{\rho^{2}}$

```python
RHS.subs(notation_subs)
```

$\displaystyle - \frac{\psi \left(\frac{\partial^{2}}{\partial \rho^{2}} q{\left(\rho,z \right)} + \frac{\partial^{2}}{\partial z^{2}} q{\left(\rho,z \right)}\right) e^{- q{\left(\rho,z \right)}}}{8}$

```python
def flat_laplacian(func):
    return ((1 / rho) * sympy.Derivative((rho * sympy.Derivative(func, rho)), rho)
             + (1 / rho) ** 2 * sympy.Derivative(sympy.Derivative(func, phi), phi)
             + sympy.Derivative(sympy.Derivative(func, z), z))
```

```python
flat_laplacian(psi).doit().subs(notation_subs)
```

$\displaystyle \frac{d^{2}}{d z^{2}} \psi + \frac{\rho \frac{d^{2}}{d \rho^{2}} \psi + \frac{d}{d \rho} \psi}{\rho} + \frac{\frac{d^{2}}{d \phi^{2}} \psi}{\rho^{2}}$

```python
gamma.matrix.inverse()
```

$\displaystyle \left[\begin{matrix}e^{- q{\left(\rho,z \right)}} & 0 & 0\\0 & e^{- q{\left(\rho,z \right)}} & 0\\0 & 0 & \frac{1}{\rho^{2}}\end{matrix}\right]$

```python

```

```python

```

```python
cov_deriv_one_form()
```

```python

```


# Simulation of a classical moving harmonic potential
## Introduction
In this notebook, we simulate a particle trapped in an harmonic potential. The potential profile is originally centered in $x=x_0$ and then moves to position $x_1$ with a constant velocity $v$ during the time range $[t_0,t_1]$. Then, the potential stops moving and we observe the oscillations.

```python
from sympy.interactive import printing
printing.init_printing(use_latex=True)
from sympy import *

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

## Theory
We begin by setting some parameters common for all calculations. Then we enter the analytical results to see if Python agrees
### Parameters

```python
x0, x1, t, t0, t1, m, v, w = symbols('x_0 x_1 t t_0 t_1 m v omega', positive=True)

B = -v/w
C = -v/w*sin(w*(t1-t0))
D = v/w * (1-cos(w*(t1-t0)))

def get_Ec(x):
    dxdt = diff(x, t)
    return Rational(1, 2) * m * dxdt**2

def get_V(x, q):
    return Rational(1, 2) * m * w**2 * (x-q)**2
```

### Movement of potential
Position and speed for $t_0 \leqslant t \leqslant t_1$:

```python
q = x0 + v*(t-t0)
x = x0 + v*(t-t0) + B*sin(w*(t-t0))
dxdt = simplify(diff(x, t))
x, dxdt
```

Potential and kinetic energy for $t_0 \leqslant t \leqslant t_1$:

```python
V_01 = get_V(x, q)
Ec_01 = simplify(get_Ec(x))
V_01, Ec_01
```

Total energy for $t_0 \leqslant t \leqslant t_1$:

```python
E_01 = simplify(Ec_01 + V_01)
E_01
```

### Potential finished moving
Position and speed for $t_1 < t$:

```python
q = x1
x = x1 + C*cos(w*(t-t1)) + D*sin(w*(t-t1))
x = simplify(x)
dxdt = diff(x, t)
x, dxdt
```

```python
x_bis = x1 + 2*v/w*cos(w*(t-(t0+t1)/2))*sin(w*(t0-t1)/2)
trigsimp(x-x_bis)
```

```python
dxdt_bis = -2*v*sin(w*(t-(t0+t1)/2))*sin(w*(t0-t1)/2)
trigsimp(dxdt-dxdt_bis)
```

```python
V_1 = factor(get_V(x, q))
V_1_theo = 2*m*v**2 * sin(w*(t-(t0+t1)/2))**2 * sin(w*(t0-t1)/2)**2
# V_1, trigsimp(get_V(x_bis, q) - V_1_theo)
# trigsimp(V_1)
# trigsimp(get_V(x_bis, q))
V_1
```

```python
Ec_1 = factor(get_Ec(x))
Ec_1
```

```python
E_1 = simplify(Ec_1 + V_1)
E_1
```

## Simulation
### Parameters

Below are the parameters of the potential and of the particle used

```python
m = 0.0011
# omega = 2*np.pi/10
omega = 1.5193
T = 2*np.pi/omega
T
```

Here we define the trajectory

```python
x0 = 0
x1 = 25
t0 = 10
t1 = t0 + 5*T/2
v = (x1-x0)/(t1-t0)
```

Finally, we define the simulation ranges

```python
x_start = -25
x_end = 50

t_start = 0
t_end = 100

dx = 0.1
dt = 0.1
```

### Trajectory
Round t1 and t0 to a precision of dt

```python
def round_to(f, d):
    return round(f) + round((f-round(f)) / d) * d
t0 = round_to(t0, dt)
t1 = round_to(t1, dt)
t0, t1
```

Define the time arrays for each phase of the movement

```python
t = np.arange(t_start, t_end, dt)
start_t0 = np.arange(t_start, t0, dt)
t0_t1 = np.arange(t0, t1, dt)
t1_end = np.arange(t1, t_end, dt)

q = np.concatenate((x0 * np.ones(len(start_t0)), v*(t0_t1-t0), x1 * np.ones(len(t1_end))))

A = 0
B = -v/omega

C = -v/omega * np.sin(omega*(t1-t0))
D = v/omega*(1-np.cos(omega*(t1-t0)))

x_start_t0 = x0 * np.ones(len(start_t0))
x_t0_t1 = x0 + v*(t0_t1-t0) + B*np.sin(omega*(t0_t1-t0))
x_t1_end = x1 + C*np.cos(omega*(t1_end-t1)) + D*np.sin(omega*(t1_end-t1))

x = np.concatenate((x_start_t0, x_t0_t1, x_t1_end))
min_x, max_x = min(x), max(x)
```

```python
position_fig = plt.figure(figsize=(16, 8), dpi=80, facecolor='w', edgecolor='k')
plt.plot(x, t, label="$x(t)$")
plt.plot([min_x, max_x], [t0, t0], '--', label='$t_0$')
plt.plot([min_x, max_x], [t1, t1], '--', label='$t_1$')
plt.xlabel("Position")
plt.ylabel("Time")
plt.legend(loc="upper left")
```

### Energy from simulation

```python
speed = np.concatenate((
    np.zeros(len(start_t0)),
    v-v*np.cos(omega*(t0_t1-t0)),
    -C*omega*np.sin(omega*(t1_end-t1)) + D*omega*np.cos(omega*(t1_end-t1))
))

Ec = 1/2 * m * speed**2
V = 1/2*m*omega**2 * (x-q)**2
E = Ec + V

energy_fig = plt.figure(figsize=(16, 8), dpi=80, facecolor='w', edgecolor='k')
plt.plot(t, Ec*1e3, label='Ec')
plt.plot(t, V*1e3, label='V')
plt.plot(t, E*1e3, label='E')
plt.legend()
plt.xlabel("Time")
plt.ylabel("Energy")
E[-1]*1e3
```

### Theoretical energy

```python
Ec = np.concatenate((
    np.zeros(len(start_t0)),
    1/2*m*v**2 * (1 - np.cos(omega*(t0_t1-t0)))**2,
    2*m*v**2 * np.sin(omega*(t1_end-(t0+t1)/2))**2 * sin(omega*(t0-t1)/2)**2
))

V = np.concatenate((
    np.zeros(len(start_t0)),
    1/2*m*v**2 * np.sin(omega*(t0_t1-t0))**2,
    2*m*v**2 * np.cos(omega*(t1_end-(t0+t1)/2))**2 * sin(omega*(t0-t1)/2)**2
))

E = np.concatenate((
    np.zeros(len(start_t0)),
    m*v**2 * (1 - np.cos(omega*(t0_t1-t0))),
    m*v**2 * (1-np.cos(omega*(t1-t0))) * np.ones(len(t1_end))
))

energy_theo_fig = plt.figure(figsize=(16, 8), dpi=80, facecolor='w', edgecolor='k')
plt.plot(t, Ec*1e3, label='Ec')
plt.plot(t, V*1e3, label='V')
plt.plot(t, E*1e3, label='E')
plt.legend()
plt.xlabel("Time")
plt.ylabel("Energy")
E[-1]*1e3
```

```python
## Animation
```

### Trajectory
Round t1 and t0 to a precision of dt

```python
def round_to(f, d):
    return round(f) + round((f-round(f)) / d) * d
t0 = round_to(t0, dt)
t1 = round_to(t1, dt)
t0, t1
```

Define the time arrays for each phase of the movement

```python
t = np.arange(t_start, t_end, dt)
start_t0 = np.arange(t_start, t0, dt)
t0_t1 = np.arange(t0, t1, dt)
t1_end = np.arange(t1, t_end, dt)

q = np.concatenate((x0 * np.ones(len(start_t0)), v*(t0_t1-t0), x1 * np.ones(len(t1_end))))

A = 0
B = -v/omega

C = -v/omega * np.sin(omega*(t1-t0))
D = v/omega*(1-np.cos(omega*(t1-t0)))

x_start_t0 = x0 * np.ones(len(start_t0))
x_t0_t1 = x0 + v*(t0_t1-t0) + B*np.sin(omega*(t0_t1-t0))
x_t1_end = x1 + C*np.cos(omega*(t1_end-t1)) + D*np.sin(omega*(t1_end-t1))

x = np.concatenate((x_start_t0, x_t0_t1, x_t1_end))
min_x, max_x = min(x), max(x)
```

```python
position_fig = plt.figure(figsize=(16, 8), dpi=80, facecolor='w', edgecolor='k')
plt.plot(x, t, label="$x(t)$")
plt.plot([min_x, max_x], [t0, t0], '--', label='$t_0$')
plt.plot([min_x, max_x], [t1, t1], '--', label='$t_1$')
plt.xlabel("Position")
plt.ylabel("Time")
plt.legend(loc="upper left")
```

### Energy from simulation

```python
speed = np.concatenate((
    np.zeros(len(start_t0)),
    v-v*np.cos(omega*(t0_t1-t0)),
    -C*omega*np.sin(omega*(t1_end-t1)) + D*omega*np.cos(omega*(t1_end-t1))
))

Ec = 1/2 * m * speed**2
V = 1/2*m*omega**2 * (x-q)**2
E = Ec + V

energy_fig = plt.figure(figsize=(16, 8), dpi=80, facecolor='w', edgecolor='k')
plt.plot(t, Ec*1e3, label='Ec')
plt.plot(t, V*1e3, label='V')
plt.plot(t, E*1e3, label='E')
plt.legend()
plt.xlabel("Time")
plt.ylabel("Energy")
E[-1]*1e3
```

### Theoretical energy

```python
Ec = np.concatenate((
    np.zeros(len(start_t0)),
    1/2*m*v**2 * (1 - np.cos(omega*(t0_t1-t0)))**2,
    2*m*v**2 * np.sin(omega*(t1_end-(t0+t1)/2))**2 * sin(omega*(t0-t1)/2)**2
))

V = np.concatenate((
    np.zeros(len(start_t0)),
    1/2*m*v**2 * np.sin(omega*(t0_t1-t0))**2,
    2*m*v**2 * np.cos(omega*(t1_end-(t0+t1)/2))**2 * sin(omega*(t0-t1)/2)**2
))

E = np.concatenate((
    np.zeros(len(start_t0)),
    m*v**2 * (1 - np.cos(omega*(t0_t1-t0))),
    m*v**2 * (1-np.cos(omega*(t1-t0))) * np.ones(len(t1_end))
))

energy_theo_fig = plt.figure(figsize=(16, 8), dpi=80, facecolor='w', edgecolor='k')
plt.plot(t, Ec*1e3, label='Ec')
plt.plot(t, V*1e3, label='V')
plt.plot(t, E*1e3, label='E')
plt.legend()
plt.xlabel("Time")
plt.ylabel("Energy")
E[-1]*1e3

```


# **KERNEL METHODS**

In the methods seen in the previous chapters, during the learning phase, the training set is used to obtain a set of parameters. In this phase, called learning, the optimal combination of the weight is achieved optimizing a certain loss function. After this learning phase, the training data is discarded and the predictions for new inputs are based only on the learned parameters. In this class of method, the training points are "remembered" by $\textbf w$. There is another class of methods, those discussed in this chapter, in which the training points are used also during the prediction phase. *Memory-based* methods involve storing the entire training data and make prediction using a certain metrics of similarity between these points and the new inputs. Kernel methods belong to the class of memory-based approach. The former methods require a long training time but the prediction is very fast, for kernel methods it is the opposite. We call the methods belonging to the first approach **parametric models**, while the methods belonging to the second **non-parametric methods**. Since the prediction phase is not fast, non-parametric methods are not appropriate for online learning.

In real world applications we are usually interested in performing nonlinear regression or nonlinear classification. Linear model are very easy to implement, fast and easy to interpret but cannot be applied in such scenarios. The two figures below represent situation where, on the left linear regression fails, and on the right fails the linear classification.

Kernel methods allow linear methods to work in nonlinear setting by mapping data to an higher dimensions where it exhibits linear patterns. They provide a different way to solve the same problem! Let's consider some example. Consider the binary classification problem in which we want to divide red points from blue points. Obviously, in the figure on the left, no linear separator exists to achieve this goal. However, we can solve this problem with the following map: $x \rightarrow \{x, x^2\}$. Data now becomes linearly separable in the new representation.

Let's look at another example in which each point is represented by two features. Also in this case no linear separator exists. As in the previous case, applying a simple map we can make the points linearly separable into an higher dimensional space. In this case the mapping is $\textbf x = \{x_1, x_2\} \rightarrow \textbf z = \{x_1^2, \sqrt{2}x_21 x_2, x_2^2\}$ and is represented in figure on the right.

Consider the following mapping $\phi$ for an example $\textbf x = \{x_1, x_2, ..., x_M\}$.

$$ \phi : \textbf x \rightarrow \{x_1^2, x_2^2, ..., x_M^2, x_1 x_2,x_2 x_3, ..., x_1 x_M, ..., x_{M-1}x_M\} $$

This is an example of quadratic mapping and, as it is possible to see, after the application of the map the number of features increases considerably and their computation can be inefficient. The application of kernel methods help avoid these issues, we will obtain the same result without computing the features since they will be implicit in the metric used to evaluate the distances between points.

Many linear parametric model can be re-cast into  an equivalent **dual representation** in which the predictions are based in a **kernel function** evaluated at training points. For models which are based on a fixed nonlinear feature mapping $\phi(\textbf x)$, the kernel function is simply given by:

$$
\begin{align}
\mathcal k (\textbf x, \textbf x') = \phi(\textbf x) ^T \phi(\textbf x)
\end{align}
$$

The kernel specifies the similarity between $\textbf x$ and $\textbf x'$ in the feature space. We can see from the definition above that the kernel is a symmetric function of its arguments. The simplest example of a kernel function is the identity function, given by the mapping $\phi(\textbf x) = \textbf x$ and we will refer to this kernel as the linear kernel.

The concept of kernel is formulated as an inner product in a feature space and allows to extend the well known algorithms by using the **kernel trick**. The basic idea of kernel trick is that, if we have an input vector that appears only in the form of scalar products then we can replace scalar products with some other choice of kernel. The kernel trick transforms a parametric model into a memory-based one with a change in computation for the result. In the kernel space the complexity is moved from the number of features to the number of points so, it is very useful when the number of features is intractable. Two important type of kernels are:

* Stationary kernels : they are a function of the difference between the arguments, so that $\mathcal k(\textbf x, \textbf x') = \mathcal k(\textbf x - \textbf x')$. They are invariant to translations in space.

* Homogeneous kernels: they depend only on the magnitude of the distance between the arguments so that $\mathcal k(\textbf x, \textbf x') = \mathcal k(||\textbf x - \textbf x'||) $. These kernels are also known as **radial basis functions**.

Note that the kernel function is a scalar, while the input vector is an $M-$dimensional entity.

## **Dual representations**

---

```python

```


### Example 6: Poisson equation

In the previous example we have seen how to solve the steady-state Laplace equation with a prescribed boundary condition and BCs that contain derivatives (Neumann BC). For this we used a pseudo-timestepping to illustrate the use of two explicit data symbols of type `Function` to derive at a steady state. In this example we are going to solve the Poisson equation (step 10 of the original tutorial series) with an initial condition to demonstrate how we can combine time-dependent functions of type `TimeFunction` with time-constant `Function` symbols. We will again implement a Python-driven outer loop for the diffusion kernel, before demonstrating how we can change the expression defining our operator to internalize the pseudo-timestepping and thus get the full performance advantage of compiled kernel loops.

Our governing equation is the Poisson equation with a souce term as the RHS:
$$\frac{\partial ^2 p}{\partial x^2} + \frac{\partial ^2 p}{\partial y^2} = b$$

Discretizing and rearranging our stencil expression yields
$$p_{i,j}^{n}=\frac{(p_{i+1,j}^{n}+p_{i-1,j}^{n})\Delta y^2+(p_{i,j+1}^{n}+p_{i,j-1}^{n})\Delta x^2-b_{i,j}^{n}\Delta x^2\Delta y^2}{2(\Delta x^2+\Delta y^2)}$$

The key difference to the Laplace example in the previous tutorial is that this example hassimple Dirichlet boundary conditions $p = 0$ on all boundaries and is instead driven by the source term $b$ defined as

$b_{i,j}=100$ at $i=nx/4, j=ny/4$

$b_{i,j}=-100$ at $i=nx*3/4, j=3/4 ny$

$b_{i,j}=0$ everywhere else.

And of course, we will demonstrate the original example first using pure NumPy notation.

```python
from examples.cfd import plot_field, init_hat
import numpy as np
%matplotlib inline

# Some variable declarations
nx = 50
ny = 50
nt  = 100
xmin = 0.
xmax = 2.
ymin = 0.
ymax = 1.

dx = (xmax - xmin) / (nx - 1)
dy = (ymax - ymin) / (ny - 1)
```

```python
#NBVAL_IGNORE_OUTPUT

# Initialization
p  = np.zeros((nx, ny))
pd = np.zeros((nx, ny))
b  = np.zeros((nx, ny))

# Source
b[int(nx / 4), int(ny / 4)]  = 100
b[int(3 * nx / 4), int(3 * ny / 4)] = -100

for it in range(nt):
    pd = p.copy()
    p[1:-1,1:-1] = (((pd[1:-1, 2:] + pd[1:-1, :-2]) * dy**2 +
                    (pd[2:, 1:-1] + pd[:-2, 1:-1]) * dx**2 -
                    b[1:-1, 1:-1] * dx**2 * dy**2) /
                    (2 * (dx**2 + dy**2)))

    p[0, :] = 0
    p[nx-1, :] = 0
    p[:, 0] = 0
    p[:, ny-1] = 0

plot_field(p, xmax=xmax, ymax=ymax, view=(30, 225))
```

We can now pretty much use our previous implementation, although we will use `pd` instead of `pn` for consistency with the original. Our boundary conditions are even simpler since they are $0$ everywhere. Our source term is neatly wrapped in the smbol `b` and we can again use a Python-driven timestepping loop with switching buffers to repeatedly execute the operator we create.

```python
#NBVAL_IGNORE_OUTPUT
from devito import Grid, Function, TimeFunction, Operator, configuration
from sympy import Eq, solve

# Silence the runtime performance logging
configuration['log_level'] = 'ERROR'

# Now with Devito we will turn `p` into `TimeFunction` object
# to make all the buffer switching implicit
grid = Grid(shape=(nx, ny), extent=(1., 2.))
p = Function(name='p', grid=grid, space_order=2)
pd = Function(name='pd', grid=grid, space_order=2)
p.data[:] = 0.
pd.data[:] = 0.

# Initialise the source term `b`
b = Function(name='b', grid=grid)
b.data[:] = 0.
b.data[int(nx / 4), int(ny / 4)]  = 100
b.data[int(3 * nx / 4), int(3 * ny / 4)] = -100

# Create Laplace equation base on `pd`
eq = Eq(pd.laplace, b)
# Let SymPy solve for the central stencil point
stencil = solve(eq, pd)[0]
# Now we let our stencil populate our second buffer `p`
eq_stencil = Eq(p, stencil)

# Create boundary condition expressions
x, y = grid.dimensions
t = grid.stepping_dim
bc = [Eq(p.indexed[x, 0], 0.)]
bc += [Eq(p.indexed[x, ny-1], 0.)]
bc += [Eq(p.indexed[0, y], 0.)]
bc += [Eq(p.indexed[nx-1, y], 0.)]

# Now we can build the operator that we need
op = Operator([eq_stencil] + bc)

# Run the outer loop explicitly in Python
for i in range(nt):
    # Determine buffer order
    if i % 2 == 0:
        _p = p
        _pd = pd
    else:
        _p = pd
        _pd = p

    # Apply operator
    op(p=_p, pd=_pd)

plot_field(p.data, xmax=xmax, ymax=ymax, view=(30, 225))
```

Nice, we get the same spikes as the pure NumPy implementation. But we still drive the time loop though Python, which is slow. So let's try and utilise Devito's time-stepping mechanism that we saw earlier for this "pseudo-timestepping".

The trick here is to again use `TimeFunction` symbols, despite the lack of a time-dependence. This will cause Devito to allocate two grid buffers instead of one, which we can directly address them via the terms `p` and `p.forward`. This will now create an internal time loop in the kernel that we can again control by supplying the `time` argument to the create operator during invocation.

One caveat, however, is that a `TimeFunction` symbol has an implicit time dimension `t`, so the symbol is denoted as `p(t, x, y)`. This entails that, when creating our boundary condition expressions, we now need honour the leading time dimension in the "low-level" notation, and ensure that we update the index `t + 1` (the equivalent to `u.forward`).

```python
#NBVAL_IGNORE_OUTPUT

# Now with Devito we will turn `p` into `TimeFunction` object
# to make all the buffer switching implicit
p = TimeFunction(name='p', grid=grid, space_order=2)
p.data[:] = 0.

# Initialise the source term `b`
b = Function(name='b', grid=grid)
b.data[:] = 0.
b.data[int(nx / 4), int(ny / 4)]  = 100
b.data[int(3 * nx / 4), int(3 * ny / 4)] = -100

# Create Laplace equation base on `p`
eq = Eq(p.laplace, b)
# Let SymPy solve for the central stencil point
stencil = solve(eq, p)[0]
# Let our stencil populate the buffer `p.forward`
eq_stencil = Eq(p.forward, stencil)

# Create boundary condition expressions
# Note that we now add an explicit "t + 1" for the time dimension.
bc = [Eq(p.indexed[t + 1, x, 0], 0.)]
bc += [Eq(p.indexed[t + 1, x, ny-1], 0.)]
bc += [Eq(p.indexed[t + 1, 0, y], 0.)]
bc += [Eq(p.indexed[t + 1, nx-1, y], 0.)]

# We can even switch performance logging back on,
# since we only require a single kernel invocation.
configuration['log_level'] = 'INFO'

# Create and execute the operator for a number of timesteps
op = Operator([eq_stencil] + bc)
op(time=nt + 1)

plot_field(p.data[0], xmax=xmax, ymax=ymax, view=(30, 225))
```


# Worksheet 1

```
%matplotlib inline
```

```
import ngcm_utils as ngcm
```

The first worksheet covers basic topics in linear algebra. There is also a basic question on nonlinear root-finding.

## Question 1

Write down the 1, 2 and $\infty$ vector norms of

\begin{equation}
  {\bf v}_1 = \begin{pmatrix} 1 \\ 3 \\ -1 \end{pmatrix}, \quad {\bf v}_2 = \begin{pmatrix} 1 \\ -2 \end{pmatrix}, \quad {\bf v}_3 = \begin{pmatrix} 1 \\ 6 \\ -3 \\ 1 \end{pmatrix}.
\end{equation}

### Answer Question 1

We know that the 1-norm is the sum of the absolute values, so

\begin{align}
  | {\bf v}_{1} |_1 &= 1 + 3 + 1 = 5, \\
  | {\bf v}_{2} |_1 &= 1 + 2 = 3, \\
  | {\bf v}_{2} |_1 &= 1 + 6 + 3 + 1 = 11.
\end{align}

The 2-norm is the square root of the sum of the squares, so

\begin{align}
  | {\bf v}_{1} |_2 &= \sqrt{1^2 + 3^2 + 1^2} = \sqrt{11} \approx 3.3166, \\
  | {\bf v}_{2} |_2 &= \sqrt{1^2 + 3^2} = \sqrt{10} \approx 2.2361, \\
  | {\bf v}_{2} |_2 &= \sqrt{1^2 + 6^2 + 3^2 + 1^2} = \sqrt{47} \approx 6.8557.
\end{align}

The $\infty$-norm is the maximum absolute value, so

\begin{align}
  | {\bf v}_{1} |_{\infty} &= 3, \\
  | {\bf v}_{2} |_{\infty} &= 2, \\
  | {\bf v}_{2} |_{\infty} &= 6.
\end{align}

Next we calculate the condition numbers numerically using python, as a cross-check. Use numpy for this, via its linear algebra subpackage `numpy.linalg`.

```
import numpy as np
import numpy.linalg as la

v1 = np.array([1.0,  3.0, -1.0])
v2 = np.array([1.0, -2.0])
v3 = np.array([1.0,  6.0, -3.0, 1.0])

for norm in [1, 2, np.inf]:
    for v in [v1, v2, v3]:
        print("The {0} norm of {1} is {2:.3}".\
              format(norm, v, la.norm(v, norm)))
```

    The 1 norm of [ 1.  3. -1.] is 5.0
    The 1 norm of [ 1. -2.] is 3.0
    The 1 norm of [ 1.  6. -3.  1.] is 11.0
    The 2 norm of [ 1.  3. -1.] is 3.32
    The 2 norm of [ 1. -2.] is 2.24
    The 2 norm of [ 1.  6. -3.  1.] is 6.86
    The inf norm of [ 1.  3. -1.] is 3.0
    The inf norm of [ 1. -2.] is 2.0
    The inf norm of [ 1.  6. -3.  1.] is 6.0

To show how this would be done in Matlab, we use the Octave mode here (note that Octave is a free software Matlab clone: not as full featured, but using very similar syntax).

```
%load_ext octavemagic
```

```octave
%%octave

v1 = [1,  3, -1];
v2 = [1, -2];
v3 = [1,  6, -3, 1];

printf("The 1 norm of v1 is %f.\n", norm(v1, 1))
printf("The 1 norm of v2 is %f.\n", norm(v2, 1))
printf("The 1 norm of v3 is %f.\n", norm(v3, 1))
printf("The 2 norm of v1 is %f.\n", norm(v1, 2))
printf("The 2 norm of v2 is %f.\n", norm(v2, 2))
printf("The 2 norm of v3 is %f.\n", norm(v3, 2))
printf("The inf norm of v1 is %f.\n", norm(v1, "inf"))
printf("The inf norm of v2 is %f.\n", norm(v2, "inf"))
printf("The inf norm of v3 is %f.\n", norm(v3, "inf"))
```

    The 1 norm of v1 is 5.000000.
    The 1 norm of v2 is 3.000000.
    The 1 norm of v3 is 11.000000.
    The 2 norm of v1 is 3.316625.
    The 2 norm of v2 is 2.236068.
    The 2 norm of v3 is 6.855655.
    The inf norm of v1 is 3.000000.
    The inf norm of v2 is 2.000000.
    The inf norm of v3 is 6.000000.

## Question 2

Find the 1 and $\infty$ norms of

\begin{equation}
  A_1 = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}, \quad A_2 = \begin{pmatrix} -3 & 2 \\ 3 & 6 \end{pmatrix}.
\end{equation}

### Answer Question 2

The 1-norm of a matrix is the maximum of the 1-norms of the **column** vectors. For $A_1$ the 1-norms of the column vectors are 4 and 6 respectively. For $A_2$ they are 6 and 8 respectively. So we have

\begin{align}
  \| A_1 \|_1 &= 6, \\
  \| A_2 \|_1 & = 8.
\end{align}

The $\infty$ norm of a matrix is the maximum of the 1-norms of the **row** vectors. For $A_1$ the 1-norms of the row vectors are 3 and 7 respectively. For $A_2$ they are 5 and 9 respectively. So we have

\begin{align}
  \| A_1 \|_{\infty} &= 7, \\
  \| A_2 \|_{\infty} & = 9.
\end{align}

It is equally straightforward to repeat this calculation with python, using the same approach.

```
A1 = np.array([[ 1.0, 2.0],[3.0, 4.0]])
A2 = np.array([[-3.0, 2.0],[3.0, 6.0]])

for norm in [1, np.inf]:
    for A in [A1, A2]:
        print("The {0} norm of \n{1}\nis {2:.3}\n".\
              format(norm, A, la.norm(A, norm)))
```

    The 1 norm of
    [[ 1.  2.]
     [ 3.  4.]]
    is 6.0

    The 1 norm of
    [[-3.  2.]
     [ 3.  6.]]
    is 8.0

    The inf norm of
    [[ 1.  2.]
     [ 3.  4.]]
    is 7.0

    The inf norm of
    [[-3.  2.]
     [ 3.  6.]]
    is 9.0

Again, using Matlab syntax we would do the following.

```octave
%%octave

A1 = [ 1 2; 3 4];
A2 = [-3 2; 3 6];

printf("The 1 norm of A1 is %f.\n", norm(A1, 1))
printf("The 1 norm of A2 is %f.\n", norm(A2, 1))
printf("The inf norm of A1 is %f.\n", norm(A1, "inf"))
printf("The inf norm of A2 is %f.\n", norm(A2, "inf"))
```

    The 1 norm of A1 is 6.000000.
    The 1 norm of A2 is 8.000000.
    The inf norm of A1 is 7.000000.
    The inf norm of A2 is 9.000000.

## Question 3

Find the condition number of the above matrices.

### Answer Question 3

The analytic calculation of the condition number requires the norm of the inverse matrices, which is **not** the inverse of the norm of the matrix. This is, of course, not useful for the numerical work.

The inverse matrices are

\begin{align}
  A_1^{-1} & = - \frac{1}{2} \begin{pmatrix} 4 & -2 \\ -3 & 1 \end{pmatrix}, \\
  A_2^{-1} & = - \frac{1}{24} \begin{pmatrix} 6 & -2 \\ -3 & -3 \end{pmatrix}.
\end{align}

It follows that the matrix norms of the inverse matrices are

\begin{align}
  \| A_1^{-1} \| & = \frac{7}{2}, & \| A_2^{-1} \|_1 & = \frac{3}{8}, \\
  \| A_1^{-1} \|_{\infty} & = 3, & \| A_2^{-1} \|_{\infty} & = \frac{1}{3}.
\end{align}

Therefore the condition numbers with respect to the 1-norm are

\begin{align}
  K(A_1) & = \| A_1 \|_1 \| A_1^{-1} \|_1 \\ & = 21, \\
  K(A_2) & = \| A_2 \|_1 \| A_2^{-1} \|_1 \\ & = 3,
\end{align}

and the condition numbers with respect to the $\infty$-norm are

\begin{align}
  K(A_1) & = \| A_1 \|_{\infty} \| A_1^{-1} \|_{\infty} \\ & = 21, \\
  K(A_2) & = \| A_2 \|_{\infty} \| A_2^{-1} \|_{\infty} \\ & = 3.
\end{align}

Note that it is chance that, in this case, they are identical: this will not be true in general.

This suggests that, if the solution of a linear system is needed as part of a numerical method, any intrinsic errors will be increased more by the matrix $A_1$ than by the matrix $A_2$, by a factor $\sim 10$.

When calculating the condition number numerically, the Singular Value Decomposition (SVD) is typically used, which does not require inverting the matrix. Thus calculating the condition number of fast, and should be done before any matrix operation to check the condition of the matrix. Within python, this is straightforward.

```
A1 = np.array([[ 1.0, 2.0],[3.0, 4.0]])
A2 = np.array([[-3.0, 2.0],[3.0, 6.0]])

for norm in [1, 2, np.inf]:
    for A in [A1, A2]:
        print("The condition number with respect to the"\
              " {0} norm of\n{1}\nis {2:.3}\n"\
              .format(norm, A, la.cond(A, norm)))
```

    The condition number with respect to the 1 norm of
    [[ 1.  2.]
     [ 3.  4.]]
    is 21.0

    The condition number with respect to the 1 norm of
    [[-3.  2.]
     [ 3.  6.]]
    is 3.0

    The condition number with respect to the 2 norm of
    [[ 1.  2.]
     [ 3.  4.]]
    is 14.9

    The condition number with respect to the 2 norm of
    [[-3.  2.]
     [ 3.  6.]]
    is 1.89

    The condition number with respect to the inf norm of
    [[ 1.  2.]
     [ 3.  4.]]
    is 21.0

    The condition number with respect to the inf norm of
    [[-3.  2.]
     [ 3.  6.]]
    is 3.0

Again, similar functions exist in Matlab.

```octave
%%octave

A1 = [ 1 2; 3 4];
A2 = [-3 2; 3 6];

printf("The condition number with respect to the 1 norm of A1 is %f.\n", \
       cond(A1, 1))
printf("The condition number with respect to the 1 norm of A2 is %f.\n", \
       cond(A2, 1))
printf("The condition number with respect to the 2 norm of A1 is %f.\n", \
       cond(A1, 2))
printf("The condition number with respect to the 2 norm of A2 is %f.\n", \
       cond(A2, 2))
printf("The condition number with respect to the inf norm of A1 is %f.\n", \
       cond(A1, "inf"))
printf("The condition number with respect to the inf norm of A2 is %f.\n", \
       cond(A2, "inf"))

```

    The condition number with respect to the 1 norm of A1 is 21.000000.
    The condition number with respect to the 1 norm of A2 is 3.000000.
    The condition number with respect to the 2 norm of A1 is 14.933034.
    The condition number with respect to the 2 norm of A2 is 1.886618.
    The condition number with respect to the inf norm of A1 is 21.000000.
    The condition number with respect to the inf norm of A2 is 3.000000.

## Question 4

Explain the difference between direct and indirect methods for solving linear systems. Give an example of when the latter may be more useful.

*This is a standard part of an exam question: see, e.g., 07/08*.

### Answer Question 4

*Direct methods* consist of a finite list of transformations of the original matrix of the coefficients that reduce the linear systems to one that is easily solved.

*Indirect* or *iterative methods*, consist of algorithms that specify a series of steps, possibly infinite, that lead closer and closer to the solution; there may not be a guarantee that they ever exactly reach it. This may not seem a very desirable feature until we remember that we cannot in any case perfectly represent an exact solution: most iterative methods provide us with a highly accurate solution in relatively few iterations.

Large, sparse matrices are ideally solved using iterative methods.

## Coding Question 1

For each of the matrices above, work out their transpose and inverse using standard python commands.

### Answer Coding Question 1

```
A1 = np.array([[ 1.0, 2.0],[3.0, 4.0]])
A2 = np.array([[-3.0, 2.0],[3.0, 6.0]])

for A in [A1, A2]:
    print("The transpose of \n{0}\nis \n{1}\n".format(A, np.transpose(A)))
    print("The inverse of \n{0}\nis \n{1}\n".format(A, la.inv(A)))
```

    The transpose of
    [[ 1.  2.]
     [ 3.  4.]]
    is
    [[ 1.  3.]
     [ 2.  4.]]

    The inverse of
    [[ 1.  2.]
     [ 3.  4.]]
    is
    [[-2.   1. ]
     [ 1.5 -0.5]]

    The transpose of
    [[-3.  2.]
     [ 3.  6.]]
    is
    [[-3.  3.]
     [ 2.  6.]]

    The inverse of
    [[-3.  2.]
     [ 3.  6.]]
    is
    [[-0.25        0.08333333]
     [ 0.125       0.125     ]]

Once again, we can use Matlab functions to do this.

```octave
%%octave

A1 = [ 1 2; 3 4];
A2 = [-3 2; 3 6];

printf("The transpose of A1 is \n")
disp(A1.')
printf("The inverse of A1 is \n")
disp(inv(A1))
printf("The transpose of A2 is \n")
disp(A2.')
printf("The inverse of A2 is \n")
disp(inv(A2))
```

    The transpose of A1 is
            1        3
            2        4
    The inverse of A1 is
      -2.00000  1.00000
      1.50000  -0.50000
    The transpose of A2 is
           -3        3
            2        6
    The inverse of A2 is
      -0.25000  0.08333
      0.12500  0.12500

## Coding Question 3

Note that coding question 2 has been effectively answered above, in the "theory" section.

Write a function that takes a matrix, computes its condition number, and reports whether the matrix is suitably well-conditions. The choice of criteria is up to you.

### Answer Coding Question 3

As we are now starting to use functions, we could define them in the notebook, or in a separate file. Here I have used a separate file : `Worksheet1_Functions.py`. The function is defined as follows.

```
ngcm.highlight_source("Worksheet1_Functions", "MatrixConditionCheck")
```

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"
   "http://www.w3.org/TR/html4/strict.dtd">

  <title></title>
  <meta http-equiv="content-type" content="text/html; charset=None">

def MatrixConditionCheck(A, MaxConditionNumber = 10.0):
    """Check the condition number of a matrix.
    Only write output to screen if the condition number is too high.
    Should return something, really."""

    ConditionNumber = la.cond(A)
    if ConditionNumber > MaxConditionNumber:
        print("The condition number of the matrix\n{0}\n"\
              "is too large (i.e., it is {1:.4} which is larger"\
              " than {2:.4}).\n".\
              format(A, ConditionNumber, MaxConditionNumber))

We can then import the module and check the function using the earlier input matrices.

```
import Worksheet1_Functions as w1

A1 = np.array([[ 1.0, 2.0],[3.0, 4.0]])
A2 = np.array([[-3.0, 2.0],[3.0, 6.0]])

w1.MatrixConditionCheck(A1)
w1.MatrixConditionCheck(A2)
```

    The condition number of the matrix
    [[ 1.  2.]
     [ 3.  4.]]
    is too large (i.e., it is 14.93 which is larger than 10.0).

Note the Matlab implementation in `Worksheet1_MyConditionNumber.m`.

```
ngcm.highlight_source_matlab("Worksheet1_MyConditionNumber.m")
```

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"
   "http://www.w3.org/TR/html4/strict.dtd">

  <title></title>
  <meta http-equiv="content-type" content="text/html; charset=None">

%
% function ConditionNumber = Worksheet1_MyConditionNumber(A)
%
% Computes the condition number of the input matrix A.
%
% Expects a square 2-D matrix A. It will complain if the condition number
% is larger than 10 (this is a ludicrously low bound, but tests that the
% function actually works with the default input).
%
function ConditionNumber = Worksheet1_MyConditionNumber(A)

% Check the input is reasonable
if (not(isnumeric(A)))
    error('The input must be a numerical array!');
elseif (ndims(A)~=2)
    error('The input must be a 2-d array!');
elseif (size(A,1)~=size(A,2))
    error('The input must be a square 2-d array!');
end

% Set up the maximum condition number
Max_ConditionNumber = 10;

% Compute the condition number
ConditionNumber = cond(A);

if (ConditionNumber > Max_ConditionNumber)
    disp(sprintf('The condition number, %g, of the matrix makes it not suitably well-conditioned (bigger than %g)', ...
        ConditionNumber, Max_ConditionNumber));
end

end

```octave
%%octave

A1 = [ 1 2; 3 4];
A2 = [-3 2; 3 6];

Worksheet1_MyConditionNumber(A1);
Worksheet1_MyConditionNumber(A2);
```

    The condition number, 14.933, of the matrix makes it not suitably well-conditioned (bigger than 10)

## Coding Question 4

Write a bisection method to find the root of

\begin{equation}
  f(x) = \tan (x) - e^{-x}, \quad x \in [0, 1].
\end{equation}

### Answer Coding Question 4

Again we have defined the function in the separate file.

```
ngcm.highlight_source("Worksheet1_Functions", "bisection")
```

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"
   "http://www.w3.org/TR/html4/strict.dtd">

  <title></title>
  <meta http-equiv="content-type" content="text/html; charset=None">

def bisection(f, interval, tolerance = 1.e-10):
    """General bisection method for a function f of one variable.
    There must be at least one root within the interval.
    Default tolerance (width of the interval) is 1e-10."""

    assert len(interval) == 2

    # Get the endpoints of the interval
    [x_min, x_max] = interval

    # Values at the ends of the domain
    f_min = f(x_min)
    f_max = f(x_max)

    # Check that at least one root lies within the interval
    assert(f_min * f_max < 0.0)

    # The loop
    x_c = (x_min + x_max) / 2.0
    f_c = f(x_c)
    iteration = 0
    while ((x_max - x_min > tolerance) and \
               (np.abs(f_c) > tolerance) and \
               (iteration < 100)):
        iteration = iteration+1
        if f_min * f_c < 0.0:
            x_max = x_c
            f_max = f_c
        else:
            x_min = x_c
            f_min = f_c
        x_c = (x_min + x_max) / 2.0
        f_c = f(x_c)

    print("The root is approximately {0} where "\
          "f is {1:.4} (tolerance {2:.4})".format(x_c, f_c, tolerance))
    return x_c

We have already imported the module. Also within that module is the function whose root we are trying to find.

```
ngcm.highlight_source("Worksheet1_Functions", "fn_worksheet1_q4")
```

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"
   "http://www.w3.org/TR/html4/strict.dtd">

  <title></title>
  <meta http-equiv="content-type" content="text/html; charset=None">

def fn_worksheet1_q4(x):
    """Simple function defined in question, f(x) = tan(x) - exp(-x)."""

    return np.tan(x) - np.exp(-x)

Test the bisection method using various tolerances.

```
# Now find the root.
x_root = w1.bisection(w1.fn_worksheet1_q4, [0, 1])
# Try changing the precision to see what difference it makes
x_root_e6 = w1.bisection(w1.fn_worksheet1_q4, [0, 1], 1e-6)
x_root_e6 = w1.bisection(w1.fn_worksheet1_q4, [0, 1], 1e-15)
```

    The root is approximately 0.531390856602 where f is -9.652e-11 (tolerance 1e-10)
    The root is approximately 0.531391143799 where f is 5.551e-07 (tolerance 1e-06)
    The root is approximately 0.531390856652 where f is 1.11e-16 (tolerance 1e-15)

A quick and dirty Matlab implementation of the bisection algorithm follows. Note that it is not implemented as a function, but directly as a script: it would be straightforward, in a similar fashion to the python above, to implement it as a function.

```octave
%%octave

% Set up the tolerance in the width of the interval
tolerance = 1e-15;

% Set up the initial bracketing interval
x_lo = 0;
x_hi = 1;

% Set up the function and the initial function values.
f = @(x)(tan(x) - exp(-x));
f_lo = f(x_lo);
f_hi = f(x_hi);

% Set up the bisection loop.
x_guess = (x_lo + x_hi) / 2;
f_guess = f(x_guess);
% Loop until the root is bracketed within the tolerance.
while (x_hi - x_lo > tolerance)

    if (f_lo * f_guess < 0)
       % The root is between the guessed value and the lower bound
       x_hi = x_guess;
       f_hi = f_guess;
    else
       % The root is between the guessed value and the upper bound
       x_lo = x_guess;
       f_lo = f_guess;
    end

    % Set the new bisection estimate
    x_guess = (x_lo + x_hi) / 2;
    f_guess = f(x_guess);

end

printf("The root is approximately %f where f is %g.\n", x_guess, f_guess)
```

    The root is approximately 0.531391 where f is -7.77156e-16.

```
from IPython.core.display import HTML
def css_styling():
    styles = open("../../IPythonNotebookStyles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

> (The cell above executes the style for this notebook. It closely follows the style used in the [12 Steps to Navier Stokes](http://lorenabarba.com/blog/cfd-python-12-steps-to-navier-stokes/) course.)


```python
from sympy import *
import numpy as np
from numpy import linalg as LA

import pandas as pd
from pandas import DataFrame, Series
import seaborn as sns
import matplotlib.pyplot as plt
import scipy.optimize as opt
```

```python
# This function calculates the Maxium Lyapunov Exponent for a given steady state

def calculate_MLE(steady_state, n_val, m_val, kc_val, kt_val, gamma_val, gamma_R_val, k_RS_val, beta_N_val,  beta_L_val, beta_R_val):

    # First we initialize the parameters and variables in the equations
    # N: Notch receptor
    # L: Ligand (jag1a)
    # R: Repressor (her6/her9)
    # n after N, L or R refers to 'neighbors'
    # gamma are degradation rates
    # beta are production rates
    # kc and ktrans correspond to interaction strength in cis and trans
    # n and m are the hill coefficients (n = m = 1 means no cooperativity)
    N, L, R, Nn, Ln, Rn, n, m, kc, kt, gamma, gamma_R, k_RS, beta_N,  beta_L, beta_R = symbols('N L R Nn Ln Rn n m kc kt gamma gamma_R k_RS beta_N beta_L beta_R')#, real = True)

    N_homo = steady_state[0] # Notch amount at the homogeneous steady state
    L_homo = steady_state[1] # Ligand amount at the homogeneous steady state
    R_homo = steady_state[2] # Repressor amount at the homogeneous steady state

    # We define the three equations for dN/dt, dL/dt and dR/dt
    eq1 = beta_N - gamma*N - kt*N*Ln - kc*N*L
    eq2 = beta_L*(1/(1+R**m)) - gamma*L - kt*Nn*L - kc*N*L
    eq3 = beta_R*((N*Ln)**n)/(k_RS + (N*Ln)**n) - gamma_R*R

    # We substitute the parameter values
    eq1_subs = eq1.subs([(beta_N,beta_N_val),(gamma,gamma_val),(kt,kt_val),(kc,kc_val)])
    eq2_subs = eq2.subs([(beta_L,beta_L_val),(gamma,gamma_val),(kt,kt_val),(kc,kc_val), (m, m_val)])
    eq3_subs = eq3.subs([(beta_R,beta_R_val),(gamma_R,gamma_R_val),(k_RS, k_RS_val), (n, n_val)])

    #We differentiate the three equations with respect to N, L and R
    a1 = diff(eq1_subs, N).subs([(N,N_homo), (L,L_homo),             (Ln,L_homo)])
    b1 = diff(eq1_subs, L).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])
    c1 = diff(eq1_subs, R).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])

    a2 = diff(eq2_subs, N).subs([(N,N_homo), (L,L_homo),             (Nn,N_homo)])
    b2 = diff(eq2_subs, L).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Nn,N_homo)])
    c2 = diff(eq2_subs, R).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Nn,N_homo)])

    a3 = diff(eq3_subs, N).subs([(N,N_homo), (L,L_homo),             (Ln,L_homo)])
    b3 = diff(eq3_subs, L).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])
    c3 = diff(eq3_subs, R).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])

    # And store these values into the 'H' array
    H = np.array([[a1, b1, c1], [a2, b2, c2], [a3, b3, c3]], dtype = 'float64')

    # Then we differentiate with respect to Nn, Ln and Rn
    a1 = diff(eq1_subs, Nn).subs([(N,N_homo), (L,L_homo),             (Ln,L_homo)])
    b1 = diff(eq1_subs, Ln).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])
    c1 = diff(eq1_subs, Rn).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])

    a2 = diff(eq2_subs, Nn).subs([(N,N_homo), (L,L_homo),             (Nn,N_homo)])
    b2 = diff(eq2_subs, Ln).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Nn,N_homo)])
    c2 = diff(eq2_subs, Rn).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Nn,N_homo)])

    a3 = diff(eq3_subs, Nn).subs([(N,N_homo), (L,L_homo),             (Ln,L_homo)])
    b3 = diff(eq3_subs, Ln).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])
    c3 = diff(eq3_subs, Rn).subs([(N,N_homo), (L,L_homo), (R,R_homo), (Ln,L_homo)])

    #And store these values into the 'B' array
    B = np.array([[a1, b1, c1], [a2, b2, c2], [a3, b3, c3]], dtype = 'float64')

    # The final matrix is H + qk*B, where qk is equal to -1 for an unidimensional arrangement of cells
    mat = H - B
    w, v = LA.eig(mat)

    return w.max() # The function returns the highest eigenvalue of the calculated matrix.
# This value is the Maxium Lyapunov Exponent used to generate the final plot
```

```python
# Now we are going to generate the matrix that contains the MLE values depending on the kc and kt.
# This is the matrix

n_points = 101
values = 10**(np.linspace(-3, 2, n_points))

# We generate matrix initially filled with zeros, were we are going to store the MLE values.
result = np.zeros((n_points,n_points))

# We initialize the parameters and variables in the equations

N, L, R, n, m, kc, kt, gamma, gamma_R, k_RS, beta_N,  beta_L, beta_R = symbols('N L R n m kc kt gamma gamma_R k_RS beta_N beta_L beta_R')#, real = True)

eq1 = beta_N - gamma*N - kt*N*L - kc*N*L
eq2 = beta_L*(1/(1+R**m)) - gamma*L - kt*N*L - kc*N*L
eq3 = beta_R*((N*L)**n)/(k_RS + (N*L)**n) - gamma_R*R

n_val = 1
m_val = 1
gamma_val = 1
gamma_R_val = 1
k_RS_val = 1
beta_N_val = 2
beta_L_val = 10
beta_R_val = 10

# We do a parameter scan for kc and kt with values logarithmically spaced
for i, kc_val in enumerate(values):
    print(kc_val)
    for j, kt_val in enumerate(values):
        eq1_subs = eq1.subs([(beta_N,beta_N_val),(gamma,gamma_val),(kt,kt_val),(kc,kc_val)])
        eq2_subs = eq2.subs([(beta_L,beta_L_val),(gamma,gamma_val),(kt,kt_val),(kc,kc_val), (m, m_val)])
        eq3_subs = eq3.subs([(beta_R,beta_R_val),(gamma_R,gamma_R_val),(k_RS, k_RS_val), (n, n_val)])

        def f(variables) :
            (N, L, R) = variables
            n = 1
            m = 1
            gamma = 1
            gamma_R = 1
            k_RS = 1
            beta_N = 2
            beta_L = 10
            beta_R = 10
            kc = kc_val
            kt = kt_val

            eq1 = beta_N - gamma*N - kt*N*L - kc*N*L
            eq2 = beta_L*(1/(1+R**m)) - gamma*L - kt*N*L - kc*N*L
            eq3 = beta_R*((N*L)**n)/(k_RS + (N*L)**n) - gamma_R*R

            return [eq1, eq2, eq3]

        # Here we calculate the values of N, L and R that correspond to the steady state
        steady_state = opt.fsolve(f, (0.5, 1.0, 1.0) )

        #With the following function we calculate the MLE value for a given steady state
        result[i,j] = calculate_MLE(steady_state, n_val = n_val, m_val = m_val, kc_val = kc_val, kt_val = kt_val,
                                                 gamma_val = gamma_val, gamma_R_val = gamma_R_val, k_RS_val = k_RS_val,
                                                 beta_N_val = beta_N_val, beta_L_val = beta_L_val, beta_R_val = beta_R_val)

```

```python
# Here we define the position of the labels in the final plot
tick_values = [value for i,value in enumerate(values) if i%20 == 0]
tick_positions = np.arange(len(tick_values))*20 +0.5
```

```python
# And finally, we generate the final plot
plt.figure(figsize = (15,10))
sns.heatmap(np.flipud(result), cmap = 'seismic', center = 0.0)
sns.lineplot([0.001, 100], 101-np.array([0.001, 100]), color = '#FF00FF')
plt.xticks(tick_positions, tick_values)
plt.xlabel('kt')
plt.yticks(np.flip(tick_positions), tick_values)
plt.ylabel('kc')
```


**Basics**

```
# import required packages

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

\begin{equation}
u =
\begin{pmatrix} x \\y\end{pmatrix}
\end{equation}

```
# numpy array [a vector]
u = np.array([1,2])
```

```
# visualizing the vector on 2-dimentional co-ordinate system
x_coords, y_coords = u
plt.scatter(x_coords, y_coords, color=["r"])
plt.axis([0, 9, 0, 6])
plt.grid('on')
plt.show()
```

```
# function to plot vector
def plot_vector2d(vector2d, origin=[0, 0], **options):
    """
    plot a vector as arrow from origin to the given vector2d
    Args: vector2d
    returns: arrow plot
    """
    return plt.arrow(origin[0], origin[1], vector2d[0], vector2d[1],
              head_width=0.2, head_length=0.3, length_includes_head=True,
              **options)
```

```
plot_vector2d(u, color="b")
plt.scatter(x_coords, y_coords, color=["r"])

plt.axis([0, 9, 0, 6])
plt.grid('on')
plt.show()
```

```
u = np.array([0,2])
v = np.array([5,0])

plot_vector2d(u, origin=[0,0], color="b")
plot_vector2d(v, origin=[0,0], color="r")

x1,x2,y1,y2 = -5, 10, -5, 5
plt.axis([x1,x2,y1,y2])
plt.grid('on')
plt.show()
```

**Rotation of 2-D vectors**

\begin{equation}Rx = x{}^{'}\end{equation}
\begin{equation}
\begin{pmatrix} cos\theta & -sin\theta \\ sin\theta & cos\theta\end{pmatrix}
\begin{pmatrix} x\\ y \end{pmatrix}
=
\begin{pmatrix} x{}^{'}\\ y{}^{'} \end{pmatrix}
\end{equation}

```
def rotate(th):
    "2-D rotational matrix"
    R = np.array([[np.cos(th), -1*np.sin(th)], [np.sin(th), np.cos(th)]])
    return R
```

**Applying roatation on the vectors**

```
th = np.pi/4
print(f'Ratation angle: \n {np.degrees(th)}')
R = rotate(th)
print(f'Rotational matrix:\n {np.round(R)}')
uR = (np.dot(u,R))
vR = (np.dot(v,R))
print(f'Rotated u: \n {uR}')
print(f'Rotated u: \n {vR}')
```

    Ratation angle:
     45.0
    Rotational matrix:
     [[ 1. -1.]
     [ 1.  1.]]
    Rotated u:
     [1.41421356 1.41421356]
    Rotated u:
     [ 3.53553391 -3.53553391]

**Plotting the actual and roatated vectors**

```

plot_vector2d(u, origin=[1,1], color="b", alpha=0.3)
plot_vector2d(v, origin=[0,0], color="r", alpha=0.3)

plot_vector2d(uR, origin=[1,1], color="b", alpha=1)
plot_vector2d(vR, origin=[0,0], color="r", alpha=1)

x1,x2,y1,y2 = -5, 10, -5, 5
plt.axis([x1,x2,y1,y2])
plt.grid('on')
plt.show()
```

**Vector Addition**

```
plot_vector2d(u, color="r")
plot_vector2d(v, color="b")
plot_vector2d(v, origin=u, color="b", linestyle="dotted")
plot_vector2d(u, origin=v, color="r", linestyle="dotted")
plot_vector2d(u+v, color="g")
plt.axis([-5, 10, -5, 5])

plt.grid('on')
plt.show()
```

```

```


## Variational Integral

$$ \frac{\int \phi^{*}\hat{H}\phi d \tau}{\int \phi^{*}\phi d \tau} \geqslant E_1 $$

This page follows Levine's text *Quantum Chemistry, 7th Ed*, Section 8.1, page 199. On that page, the author describes how to use a trial wavefunction to solve for an approximate energy for a 1-dimensional particle in a box. In this post, I implement the solution using SymPy to evaluate the approximate energy level.

## Our system: A one-dimensional particle in a box

### Exact solution

The exact wavefunction and energy levels for the 1-dimensional particle in a box are:

$$ \psi_{n}(x) = \sqrt{\frac{2}{l}} \sin\Bigl(\frac{n \pi x}{l}\Bigr) $$

$$ E_n = \frac{n^2 h^2}{8 m l^2} $$

$$ n = 1, 2, 3, \dots $$

$$ 0 \leqslant x \leqslant l $$

Where \\(E_n\\) is entirely kinetic and incorporates no potential energy. At the ground state of \\(n = 1\\), the energy is:

$$ E_1 = \frac{h^2}{8 m l^2} $$

### Exact Hamiltonian

The Hamiltonian for the 1-dimensional particle in a box is:

$$ \hat{H} = -\frac{\hbar^2}{2 m} \frac{d^2}{dx^2} $$

### Trial Variation Function

$$ \phi = x(l - x) $$

Where \\(\phi\\) is not normalized.

### Note about SymPy notation

In its notation SymPy is going to denote \\(\hbar\\) by \\(h_{bar}\\)

## Evaluate the variational integral

### Denominator of the variational integral

Because our trial is real variation function \\(x(l - x)\\), the denominator of our variational integral is:

$$ \int \phi^{*} \phi d \tau = \int_{0}^{l} [x(l - x)]^2 dx $$

As evaluated with SymPy:

```python
from sympy import Symbol, symbols, oo, diff, Function, integrate
x, l, h_bar, m = symbols('x l h_bar m')
denominator = integrate((x*(l-x))**2, (x, 0, l))
denominator
```

$\displaystyle \frac{l^{5}}{30}$

### Numerator of the variational integral

The numerator is more complicated because of the Hamiltonian operator \\(\hat{H}\\):

$$ \int \phi^{*}\hat{H}\phi d \tau = -\frac{\hbar^2}{2 m} \int_{0}^{l} x(l-x) \Biggl[\frac{d^2}{dx^2} x (l-x)\Biggr] dx $$

As evaluated with SymPy:

```python
numerator = (-h_bar**2 / (2*m)) * integrate((x*(l - x)) * diff(x*(l - x), x, 2), (x, 0, l))
numerator
```

$\displaystyle \frac{h_{bar}^{2} l^{3}}{6 m}$

### Putting it all together and simplifying

$$ \frac{\int \phi^{*}\hat{H}\phi d \tau}{\int \phi^{*}\phi d \tau} = \frac{-\frac{\hbar^2}{2 m} \int_{0}^{l} x(l-x) \bigl[\frac{d^2}{dx^2} x (l-x)\bigr] dx}{\int_{0}^{l} [x(l - x)]^2 dx} = \frac{\hbar^2 l^3 / 6 m}{l^5 / 30} = \frac{5 \hbar^2}{l^2 m} $$

As evaluated with SymPy:

```python
numerator / denominator
```

$\displaystyle \frac{5 h_{bar}^{2}}{l^{2} m}$

## Comparing variational energy with exact energy

### Variational energy

Recalling that \\(\hbar = h / 2 \pi\\), and with some refactoring, we can rewrite the expression as:

$$ \frac{5 \hbar^2}{l^2 m} = \frac{5}{4 \pi^2} \frac{h^2}{m l^2} = 0.127 \frac{h^2}{m l^2} $$

### Exact energy

$$ \frac{h^2}{8 m l^2} = \frac{1}{8} \frac{h^2}{m l^2} = 0.125 \frac{h^2}{m l^2} $$

```python

```


```
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.density import *
from sympy.physics.quantum.spin import (
    Jx, Jy, Jz, Jplus, Jminus, J2,
    JxBra, JyBra, JzBra,
    JxKet, JyKet, JzKet,
)
from IPython.core.display import display_pretty
from sympy.physics.quantum.operator import *

%load_ext sympyprinting
```

##Fidelity using some kets

```
up = JzKet(S(1)/2,S(1)/2)
down = JzKet(S(1)/2,-S(1)/2)
amp = 1/sqrt(2)
updown = (amp * up ) + (amp * down)

# represent turns Kets into matrices
up_dm = represent(up * Dagger(up))
down_dm = represent(down * Dagger(down))
updown_dm = represent(updown * Dagger(updown))
updown2 = (sqrt(3)/2 )* up + (1/2)*down

display_pretty(fidelity(up_dm, up_dm))
display_pretty(fidelity(up_dm, down_dm)) #orthogonal states
display_pretty(fidelity(up_dm, updown_dm).evalf())

# alternatively, puts Kets into Density object and compute fidelity
d1 = Density( [updown, 0.25], [updown2, 0.75])
d2 = Density( [updown, 0.75], [updown2, 0.25])
display_pretty(fidelity(d1, d2))
```

    1

    0

    0.707106781186548

    0.817293551913876

## Fidelity on states as Qubits

```

from sympy.physics.quantum.qubit import Qubit
state1 = Qubit('0')
state2 = Qubit('1')
state3 = (1/sqrt(2))*state1 + (1/sqrt(2))*state2
state4 = (sqrt(S(2)/3))*state1 + (1/sqrt(3))*state2

state1_dm = Density([state1, 1])
state2_dm = Density([state2, 1])
state3_dm = Density([state3, 1])

# mixed qubit states in density
d1 = Density([state3, 0.70], [state4, 0.30])
d2 = Density([state3, 0.20], [state4, 0.80])

display_pretty(fidelity(d1, d2))

```

    0.996370452558227

```

```