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

This notebook explores the background of the Kalman filter.

# Weighting the past against the present

The central idea of the Kalman filter is to weight the past against the present.
We're actually very familiar with this idea and ues it whenever averaging or low-pass filtering.

## Averaging

If we want to estimate some value $X$ but only have access to noisy measurements, the usual approach is to take $N$ noisy measurements and average them out to get an estimate of $X$. For
 - $X$ the true value
 - $\tilde{X}_n$ a noisy measurement of $X$
 - $\hat{X}_N$ an estimate of $X$ given $N$ measurements

$$
\hat{X}_N = \frac{1}{N}\sum_{n=1}^N\tilde{X}_n
$$

Now, let's say that these measurements are arriving in time. We can express our current estimate in terms of our previous estimate

\begin{align}
\hat{X}_N &= \frac{1}{N}\sum_{n=1}^N\tilde{X}_n \\
    &= \frac{1}{N}(\tilde{X}_1+\tilde{X}_2+\ldots+\tilde{X}_{N-1}+\tilde{X}_N) \\
    &= \frac{N-1}{N}\frac{1}{N-1}(\tilde{X}_1+\tilde{X}_2+\ldots+\tilde{X}_{N-2}+\tilde{X}_{N-1}) + \frac{1}{N}\tilde{X}_N \\
    &= \frac{N-1}{N}\hat{X}_{N-1} + \frac{1}{N}\tilde{X}_N \\
\end{align}

We give our past estimate a weight of $\frac{N-1}{N}$ and our current measurement a weight of $\frac{1}{N}$.

We can also rewrite our current estimate as the sum of our previous estimate and the weighted difference between our current measurement and previous estimate.

$$
\hat{X}_N = \hat{X}_{N-1} + \frac{1}{N}(\tilde{X}_N-\hat{X}_{N-1}) \\
$$

Now the interpretation is that we are adjusting our previous estimate by the weighted difference between the measurement and previous estimate (i.e., what we might have expected to measure).

## Low Pass Filtering

We can see the same idea of weighting the a past estimate against a current measurement in a low-pass filter

$$\dot{\hat{X}}=\frac{1}{\tau}(-\hat{X}+\tilde{X})$$

By converting to discrete time using the Euler step*

\begin{align}
\hat{X}_T &= \hat{X}_{T-1} + \frac{\Delta t}{\tau}(-\hat{X}_{T-1} + \tilde{X}_{T-1}) \\
&= \left(1-\frac{\Delta t}{\tau}\right)\hat{X}_{T-1} + \frac{\Delta t}{\tau}\tilde{X}_{T-1}
\end{align}

We give our past estimate a weight of $\left(1-\frac{\Delta t}{\tau}\right)$ and our current measurement a weight of $\frac{\Delta t}{\tau}$. Note how this differs from the averaging case. Here the weights are constant, while with averaging, the weight changes with the number of samples. This difference follows from the different purposes of these processes---averaging is used when the underlying value doesn't change while low-pass filtering is used when the underlying value does.

As with averaging, we can rewrite our low-pass filter estimate in terms of our previous estimate and the diference between the current measurement and previous estimate.

$$\hat{X}_T = \hat{X}_{T-1} + \frac{\Delta t}{\tau}(\tilde{X}_{T-1}-\hat{X}_{T-1})$$

\* $\dot{X}\approx(X_T-X_{T-1})/\Delta t$ so $X_T\approx X_{T-1}+\Delta t\dot{X}$

# The Kalman Filter

The Kalman filter uses an underlying dynamical system model of

```
x[t] = Ax[t-1] + Bu[t-1] + w[t-1]
z[t] = Hx[t] + v[t]
```

where
 - $x$ is the state variable
 - $u$ is the input
 - $w$ is Gaussian process noise with covariance $Q$. There may be things outside of our control that influence the system.
 - $z$ is our measurement of $x$ and $H$ is the transformation between $x$ and $z$. $H$ may very well be identity.
 - $v$ is Gaussian measurement noise with covariance $R$. Our measurements themselves may be noisy, independent of the underlying process being measured.

Based on the dynamical system model, we want to build an estimate, $\hat{x}$, of $x$ using measurements $z$ and our model of the system. Our prediction will take the form

```
x_hat[t] = x_hat_predict[t]+K[t]y[t]
```

where `K` is the Kalman Gain and `y` is the difference between what we measured and what we predicted we would measure. The Kalman Gain dictates how much to weight to give our prediction vs what we observed.

For each time step, we use the previous time step's result to predict `x` at the current time step; we also estimate our prediction's error covariance, `P`.

```
x_hat_predict[t] = A x_hat[t-1] + B u[t-1]
P_hat_predict[t] = A P_hat[t-1] A.T + Q
```

Note how the current state prediction, `x_hat_predict[t]`, arises from simply ticking the system dynamics model forward one time step using the previous state estimation, `x_hat[t-1]`. We similarly tick the dynamics model forward to update the prediction covariance `P_hat_predict` but also account for process noise, `w`, by adding the process noise covariance `Q`.

Besides predicting the current state, we also predict the current measurement `z` and its covariance.

```
z_predict[t] = H x_hat_predict[t]
S_measure[t] = H P_hat_predict[t] H.T + R
```

We then take a measurement and note the difference between what we measured and what we predicted

```
y[t] = z[t]-z_predict[t]
```

`y` is called the _innovation_.

We then decide how much to weight what we expected against the surprise by taking the ratio of the estimated process noise and the measurement noise.

```
S_process[t] = P_hat_predict[t] H.T
K[t] = S_process S_measure[t]**-1
```

`K` is the Kalman gain, and we use it to update our estimate post measurement.

```
x_hat[t] = x_hat_predict[t]+K[t]y[t]
```

Note how this can be rewritten as weighting our current expectation against the current measurement.

```
x_hat[t] = (1-K[t])xhat_predict[t]+K[t]z[t]
```

We also use `K` to update our estimate of the error covariance

```
P_hat[t] = (I-K[t])P_hat_predict[t]
```

This completes the step, and from here, the Kalman filter repeats.

```python
A = 1
B = 0
H = 1

Q = 0.001 # process noise
R = 0.001 # measurement noise
p_hat_0 = 2*Q
x_0 = 0.5
x_hat_0 = 0.5
T = 10

x = np.zeros(T) # state
x[0] = x_0
u = np.zeros_like(x)
w = np.random.normal(scale=Q, size=x.shape[0]) # process noise
# w = np.zeros_like(x) # process noise

x_hat_predict = np.zeros_like(x) # predicted state estimate
x_hat = np.zeros_like(x) # state estimate
x_hat_predict[0] = x_hat_0
x_hat[0] = x_hat_0

p_hat_predict = np.zeros_like(x) # state estimate covariance
p_hat = np.zeros_like(x) # state estimate covariance
p_hat_predict[0] = p_hat_0
p_hat[0] = p_hat_0

z_predict = np.zeros_like(x) # predicted measurement
z = np.zeros_like(x) # measurement
s_process = np.zeros_like(x) # predicted state estimate covariance projected into measurement space
s_measure = np.zeros_like(x) # predicted measurement covariance
v = np.random.normal(scale=R, size=x.shape[0]) # measurement noise

K = np.zeros_like(x) # kalman gain

for t in range(T)[1:]:
    # step the underlying process
    x[t] = A*x[t-1]+B*u[t-1]+w[t-1]

    # make predictions of the current state and its covariance
    x_hat_predict[t] = A*x_hat[t-1] + B*u[t-1]
    p_hat_predict[t] = A*p_hat[t-1]*A + Q
    # make predictions of the current measurement and its covariance
    z_predict[t] = H * x_hat_predict[t]
    s_measure[t] = H * p_hat_predict[t] * H + R

    # calculate the kalman gain
    s_process[t] = p_hat_predict[t] * H # project state estimation into measurement space
    K[t] = s_process[t]*s_measure[t]**-1

    # take a measurement
    z[t] = H*x[t]+v[t]
    # calculate the innovation
    y = z[t] - z_predict[t]

    # update state and covariance estimates with weighted innovation
    x_hat[t] = x_hat_predict[t] + K[t]*y
    p_hat[t] = (1-K[t]*H)*p_hat_predict[t]

T_plot = range(T)[1:]

plt.figure()
plt.plot(x, label="truth")
plt.plot(T_plot, z[1:], label="measured")
plt.plot(x_hat, label="estimations")
plt.legend(loc="best")

fig, axs = plt.subplots(ncols=2, figsize=(14,4))
axs[0].plot(T_plot, p_hat_predict[1:], label="state estimation predicted variation")
axs[0].plot(T_plot, p_hat[1:], label="state estimation predicted variation corrected")
axs[0].legend(loc="best")
axs[1].plot(T_plot, s_process[1:], label="state estimate variation in measurement space")
axs[1].plot(T_plot, s_measure[1:], label="measurement variation")
axs[1].legend(loc="best")

plt.figure()
plt.plot(T_plot, K[1:], label="kalman gain")
plt.legend(loc="best")

plt.show()
```

# Limitations and Usage

The Kalman filter assumes the system dynamics and noise characteristics. If these assumptions do not match reality, then the KF will have a systematic bias.

Is there a way to learn the dynamics and noise model to fit the KF?

# Continuous Time

To convert discrete to continuous time, recall Euler's Method.

\begin{align}
\frac{dx}{dt} &= \frac{x_{t}-x_{t-1}}{\Delta t} \\
x_{t} &= x_{t-1} + \Delta t\frac{dx}{dt}
\end{align}

So given the discrete and continuous time equations

\begin{align}
x_{t} &= A^{DT}x_{t-1} + B^{DT}u_{t-1} \\
\frac{dx}{dt} &= A^{CT}x + B^{CT}u
\end{align}

If we plug Euler's Method in to the discrete equation,

\begin{align}
x_{t-1} + \Delta t\frac{dx}{dt} &= A^{DT}x_{t-1} + B^{DT}u_{t-1} \\
\frac{dx}{dt} &= \frac{1}{\Delta t}(A^{DT}-I)x_{t-1} + \frac{1}{\Delta t}B^{DT}u_{t-1}
\end{align}

Therefore to convert discrete time matrices to continuous time, use

\begin{align}
A^{CT} &= \frac{1}{\Delta t}(A^{DT}-I) \\
B^{CT} &= \frac{1}{\Delta t}B^{DT}
\end{align}

so that the continuous form of the Kalman filter will be

\begin{align}
\dot{x} &= \frac{1}{\Delta t}(A-I)x+\frac{1}{\Delta t}Bu + w \\
z &= Hx + v
\end{align}

# Helpful Links

- http://robotsforroboticists.com/kalman-filtering/
- http://www.cs.unc.edu/~welch/media/pdf/kalman_intro.pdf


# Binomial Basics
This notebook is a record of how one gets from counting some things to systematically counting 'states'.

```python
from sympy import init_session
#To print all output in html.
from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = "all"
init_session()
%matplotlib inline
```

    IPython console for SymPy 1.0 (Python 3.5.2-64-bit) (ground types: python)

    These commands were executed:
    >>> from __future__ import division
    >>> from sympy import *
    >>> x, y, z, t = symbols('x y z t')
    >>> k, m, n = symbols('k m n', integer=True)
    >>> f, g, h = symbols('f g h', cls=Function)
    >>> init_printing()

    Documentation can be found at http://docs.sympy.org/1.0/

### Permutations of **n** items
$P(n) = n!$. This states that, if one takes an item (from a bag), $x_1$, one is left with $n - 1$ items. Take the next item, $x_2$ out, and there are $n - 2$ items left. Illustrate this with three items, ${a, b, c}$. If I chose a, I could choose either b or c. If I chose b, then the last choice is c. If I chose c, the last choice is b. Carry this process on and one ends up with a tree of choice pathways. The end results is that in choosing item 1, I have 3 options, 2 options for item 2 and 1 for item 3. So the total number of options in the tree is 3 x 2 x 1 = 3!

In the general sense, this tells us that, if one has $n$ items, that the number of **permutations** $P(n) = n!$

Lets look at how to use this process with ```sympy```.

```python
from sympy.functions.combinatorial.numbers import nC, nP, binomial, bernoulli, factorial
items = ['a','b','c']
#How many wasy can one choose all three of the items in the list?
print('There are ',nP(items, 3),'ways to choose each item, to arrange the three items, 3 at a time.')
```

    There are  6 ways to choose each item, to arrange the three items, 3 at a time.

But, what if one only wants to pick two items of a three item list at a time? This is asking how many ways can one pick $k$ items from a list of $n$ items? $P(n, k)$.

The answer to this question is, in my opinion, both elegant and fundamental to how to think about counting and combining (combinatorics).

Imagine that one starts taking out items, until $k$ items are removed. What is left are $n - k$ items. But we know how to count how many ways one can arrange those items; it is $(n - k)!$. Simple.

We still don't know what $P(n,k)$ evaluates to. But, we do know that $P(n) = P(n,k)\ P(n, n - k) = P(n,k) \ (n - k)!$ That is, the total combinations is a factorial of $n$; this leaves only one unknown algebraic term. If we solve for this using the other terms, we can derive $P(n,k)$ in terms of things we do know and can count more easily.

Lets do some simple substitution:
$$ P(n) = P(n,k) \ P(n, n - k)$$

$$ P(n) = n!$$

$$ P(n, n - k) = (n - k)!$$

$$ n! = P(n,k) \ (n - k)!$$

$$ P(n,k) = \frac{n!}{(n - k)!}$$

This is great; we now can find the number of ways items can be ordered when drawing from a pool of $n$ items.

In ```sympy.nP()``` this simply involves making $k \in n :  k \le n$. [sympy combinatorics](http://docs.sympy.org/latest/modules/functions/combinatorial.html#np)

```python
items = ['a','b','c','d']
k = 2
nP(items, k)
```

This gets one to the most interesting part. This is where we can find and calculate the **binomial coefficient**. But first some preliminaries...

The phrase “combinations of $n$ distinct items taken $k$ at a time” means the ways in which $k$ of the $n$ items can be combined, regardless of order. (from [source](http://www.zipcon.net/~swhite/docs/math/probability/counting.html))

So rather than considering the orders in which items are chosen, as with permutations, the combinations consider which **sets** of items are chosen.

The combinations of $n$ distinct items taken $k$ at a time is mainly written ${n}\choose{k}$.

Separate the issue of the **order** in which the items are chosen from the issue of **which** items are chosen, as follows. From before, the number permutations of $k$ items taken from $n$ items is:

        (number sets of k items taken from n ) × ( number of ways to order the k items )

$$P(n,k) = {{n}\choose{k}} \ P(k,k)$$

Rearrange this a little, as we actually know all the terms, apart from ${n}\choose{k}$.

$${{n}\choose{k}} = \frac{P(n,k)}{P(k,k)}$$

$${{n}\choose{k}} = \frac{n!}{k!(n - k)!}$$

Lets test this in ```sympy```.

```python
binomial(4,k)
```

```python
#Does this derivation work out when we compare it to the sympy...binomial function?
binomial(4,k) == nP(4,k)/nP(k,k)
```

    True

```python
#Digging slightly deeper, one can expose the expressions used to calculate elements.
num_items = Symbol('n',positive=True, integer=True)
binomial(num_items,k).expand(func=True)
```

The only issue with this approach, symbolic as it is, is that it just is hard to calculate out for anything large. In part, this arises from the need and cost of computing factorials. ```sympy``` does allow a Stirling approximation, but this is just a computational expedient.

One could derive a symbolic result and then use lambdify or theanofunction to apply this numerically. Again, this would work quite well, but is cumbersome.

### Creating actual samples from a distribution using sympy

The [sympy.stats](http://docs.sympy.org/latest/modules/stats.html) documentation provides examples how to generate distributions from a symbolic object (distribution).

```python
from sympy.stats import *
```

```python
n = 5
p=0.5
BinRV = Binomial("BinRV",n,p)
#We've created an object and now can inspect it's density, for example.
density(BinRV).dict
```

There are a number of other expressions that operate on the RV object.
Queries on random expressions can be made using the functions

|Expression | Meaning
|--- | ----- |
|P(condition)| Probability
|E(expression)| Expected value
|variance(expression)| Variance
|density(expression) | Probability Density Function
|sample(expression)| Produce a realization
|where(condition)| Where the condition is true

```python
#Wat is the probability that our RV has more than 2 successes out of the 5 draws?
P(BinRV>2)
```

```python
#What is the EV,  or mean value and variance?
E(BinRV), variance(BinRV)
```

```python
for i in range(5):
    sample(BinRV)
```

```python
where(BinRV < 3).set
```

```python
density(BinRV)(x)
```

```python
#You can create an expression for any probability of your RV when one has sampled n times.
num = 5
prob = t
D = Binomial('D', num, prob)
density(D)(x)
```

```python
#Check that the probability from the expression == density estimate.
Eq(t**5).lhs.subs(t,.5) == .5**5
```

    True

```python
expand(Eq(5*t*(1-t)**4))
```

### Pascal's triangle and the Binomial coefficient

Lets go back to a very basic algorithm used to construct Pascal's triangle. Let $n$ be the row number; let $k$ be the column number. The algorithm is:

$$ {{n}\choose{k}} = {{n - 1}\choose{k - 1}} + {{n - 1}\choose{k}}$$

Apply this algorithm and we get:

$$1$$
$$1 \ 1 $$
$$1 \ 2 \ 1 $$
$$1 \ 3 \ 3 \ 1 $$
$$1 \ 4 \ 6 \ 4 \ 1 $$
$$1 \ 5 \ 10 \ 10 \ 5 \ 1 $$

```python
#One pythonic approach to generate a list of binomial coefficients.
for i in range(6):
    [binomial(i,k) for k in range(6)]
```

These are coefficients in the same sense of any coefficient which multiplies a variable $x^n$.
In ```sympy``` it is not difficult to create an equation for however many coefficients one desires. The basic equation, the binomial equation, is expanded out with $n$ representing the row again.

$$ Binomial = (1 + x)^n$$

```python
#These are the coefficients for the expansion of
for i in range(6):
    expand((1+x)**i)
```

Another property of the binomial coefficients is derived from setting $ x = 1$ and rewriting the equation:
$$(1 + x)^n\Big|_{x = 1} = \sum_{k=0}^{n} {{n}\choose{k}} x^n\Big|_{x = 1}$$

Let $ x = 1$, giving:

$$\Rightarrow (1 + 1)^n = \sum_{k=0}^{n} {{n}\choose{k}} 1^n$$
$$\Rightarrow 2^n = \sum_{k=0}^{n} {{n}\choose{k}}$$

There are a number of other, quite surprising, properties of binomial coefficients and Pascal's triangle, [see for example](http://www.cs.columbia.edu/~cs4205/files/CM4.pdf)

### Numeric methods of finding the binomial coefficients
As noted before, whilst ```sympy``` is very useful algebraically, it may not be the most computationally efficient option. The following introduces the ```scipy.stats``` algorithm. Within that package, there is a [constructor](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.binom.html) to allow creation of binomial distributions.

```python
from scipy.stats import binom
#For a single coin-toss for heads = 1 we get...
n, p = 1, 0.5
mean, var, skew, kurt = binom.stats(n, p, moments='mvsk')
#These are not normalised, so need to be dividded by n to attain this...
mean, var
```

    (array(0.5), array(0.25))

Usually, for practical purposes, you will want a 'frozen' distribution (a list of samples). This is achieved as follows (you  can read the docs for other options).

```python
#Lets do n = 10 Bernoulli trials with probability p = .5
n =5
p = 0.5
```

```python
#Some basic elements of the functions... first the percent point function.
#This shows the range of outcomes from 1% to 99%
binom.ppf(.01,n,p),binom.ppf(.99,n,p)
```

```python
bin_dist = binom(n,p) #Creates a frozen object which fixes shape and location.
#Pick some x-values for which we want a distribution
X = np.arange(binom.ppf(0.01, n, p),binom.ppf(0.99, n, p))
X, bin_dist.pmf(X)
```

    (array([0., 1., 2., 3., 4.]),
     array([0.03125, 0.15625, 0.3125 , 0.3125 , 0.15625]))

```python
#I personally don't find this method of gaining a pmf overly clear. But, it
# allows one to create an object and then plot a pmf for each of the created x-values.
plt.vlines(X,0,bin_dist.pmf(X))
```

```python
#An alternative way is to create a set of random variables and then plot these
#as a histogram.
RV = binom.rvs(n,p,size=10000)
RV[:10], plt.hist(RV,
                  bins=[0,1,2,3,4,5,6],#Setting bin range is just tider...
                  align='left',#puts the bars onto the value
                  rwidth=.5,
                  normed=True) #Presents the hist so the sum of bars = 1
```

```python
#what about the stats for the distribution we have sampled?
print('Mean: ',RV.mean(), 'Standard Deviation: ',RV.var())
```

    Mean:  2.5068 Standard Deviation:  1.2479537600000001

```python
#How does this compare to the first method of constructing a distribution?
size=10000
mean, var, skew, kurt = binom.stats(size, p, moments='mvsk')
n,p,mean/size, var/size
```

### Using Bayesian modelling to estimate parameters from a set of samples

While it is great to be able to generate distributions, and to model them, mostly in applied settings one usually has a set of data from counting successes (or failures) from which the goal is to estimate the probability of a *single* success.The approach above is to find the mean, var etc using expected value formulae. However, it is important to recall that these formulae are for distributions in the limit. That is not what one ever has in terms of data, typically.

```python
import pymc3 as pm
model = pm.Model()

with model:
    p = pm.Beta( 'p', alpha=2, beta=2 )
    #Lets also estimate the mean and variance.
    mean = pm.Deterministic('Mean', n*p)
    variance = pm.Deterministic('Variance', n*p*(1-p))
    y_obs = pm.Binomial( 'y_obs', p=p, n=n, observed=RV )
    trace = pm.sample( 10000, progressbar=True )

pm.traceplot( trace )
```

```python
pm.summary(trace)
```

    p:

      Mean             SD               MC Error         95% HPD interval
      -------------------------------------------------------------------

      0.501            0.002            0.000            [0.497, 0.506]

      Posterior quantiles:
      2.5            25             50             75             97.5
      |--------------|==============|==============|--------------|

      0.497          0.500          0.501          0.503          0.506

    Mean:

      Mean             SD               MC Error         95% HPD interval
      -------------------------------------------------------------------

      2.507            0.011            0.000            [2.483, 2.528]

      Posterior quantiles:
      2.5            25             50             75             97.5
      |--------------|==============|==============|--------------|

      2.485          2.500          2.507          2.515          2.529

    Variance:

      Mean             SD               MC Error         95% HPD interval
      -------------------------------------------------------------------

      1.250            0.000            0.000            [1.250, 1.250]

      Posterior quantiles:
      2.5            25             50             75             97.5
      |--------------|==============|==============|--------------|

      1.250          1.250          1.250          1.250          1.250

```python
pm.forestplot(trace)
```

```python
pm.plot_posterior(trace['p'], kde_plot=True, rope=[0.499, .501])
```

```python
import seaborn as sns
sns.set(style="darkgrid")
y_samp = pm.sample_ppc(trace, 100, model, size=len(RV))
for i in y_samp['y_obs']:
    sns.kdeplot(i,shade=True)
```

```python

```


# Problem 10
## Summation of primes

## To do list
* Import sympy.
* Create a variable to store the sum.
* Run a loop upto 2 million.
* Print the sum.

## Importing sympy
Importing our module sympy to check whether any number is prime or not.

```python
import sympy as smp
```

## Creating variable
Creating a variable to store the result of all summations. We already added 2 in it which is the only even prime number.

```python
summation = 2
```

## Looping till 2 million
Running a loop till 2 million with a jump of 2 to ignore even numbers.

```python
for i in range(3, 2000000, 2):
    if smp.isprime(i) == True:
        summation += i
```

## Printing result
Printing the final sum of summation after adding all primes upto 2 million.

```python
print("The sum of all the prime numbers upto 2 million is:", summation)
```


The parameters of the system, _m, k, z,_ and the yielding force are given, all the remaining quantities are computed.

```
mass = 16e3
stif = 2.5e6
zeta = 0.05

fy   = 5e3
uy   = fy/stif

wn   = sqrt(stif/mass)
wd   = wn*sqrt(1-zeta**2)
damp = 2*wn*mass*zeta
print mass, stif, fy, uy, wn, wd, damp
```

    16000.0 2500000.0 5000.0 0.002 12.5 12.4843652221 20000.0

The initial conditions are assigned

```
x0 =  1e-3
v0 = 25e-3
```

Next, the constants of integration such that the homogeneous solution satisfies the initial conditions and the definition of a displacement function and a velocity function valid in the elastic phase

```
# x(0) = A ; v(0) = - zeta wn A + wd B => v(0) + zeta wn x(0) = wd B
A = x0 ; B = (v0+zeta*wn*A)/wd
def x_el0(t, A=A, B=B): return exp(-zeta*wn*t)*(A*cos(wd*t)+B*sin(wd*t))
def v_el0(t, A=A, B=B): return exp(-zeta*wn*t)*((B*wd-A*wn*zeta)*cos(wd*t)-(B*wn*zeta+A*wd)*sin(wd*t))
```

Just to be sure, let's verify that the initial conditions are OK

```
x_el0(0), v_el0(0)
```

    (0.001, 0.025000000000000001)

Now, let's plot our elastic response and take note that $x_\text{max}>2\\,$mm${}=u_\text{y}$.

```
t = linspace(0,0.5, 1001)
plot(t*1000,1000*x_el0(t))
grid();xlabel('t/ms');ylabel('x/mm');
```

The solution that we have is valid **only** for $x \le u_\text{y}$, so we have to find the time $t_1$ such that $x=u_\text{y}$, and then find $x_1$ and $v_1$, the last valid values for our current solution and the initial values for our next solution.

```
from scipy.optimize import newton
t1 = newton(lambda x:x_el0(x)-.002, 0.0)
x1 = uy
v1 = v_el0(t1)
```

In the plastic range, until unloading (i.e., for $v\ge0$), the equation of motion is

\begin{align}m\ddot x + c\dot x + f_\text{S}=0\end{align}

and being $f_\text{S}=f_\text{y}$ we have

$$m\ddot x + c\dot x = -f_\text{y};$$

the particular integral is $\xi = -f_\text{y} t/c$ and the general integral can be written

$$x(t) = A \exp(-ct/m) -f_\text{y}t/c + B,$$

where, as usual, the constants of integraton are determined by the initial conditions.

Knowing the constants of integration, you can define the functions that give the displacement and the velocity in the interval from yielding to unloading.

```
# x2(t-t1) = x2(t2) = A exp(-c t2 / m) - fy t2 / c + B ; v2(t2) = - c A / m exp(...) - fy/c
# x2(0) = A+B = x1 ; v2(0) = - A c/m -fy/c = v1
A = - ( v1 + fy/damp ) * mass / damp
B = x1 - A
print A, B
def x_pl(t, A=A, B=B): return A*exp(-damp*t/mass)-fy*t/damp+B
def v_pl(t, A=A, B=B): return -damp*A*exp(-damp*t/mass)/mass -fy/damp
```

    -0.208312097754 0.210312097754

Using the newton function we find _t2_, the time (NB measured starting from _t1_) at which the velocity is equal to zero,

```
t2 = newton(v_pl,t1)
```

so that we can compute the position and the velocity at the end of the plastic phase or, equivalently, at the beginning of the unloading. Also, in the following we'll need the total plastic deformation, so it is computed as the difference between the position at the end and the position at the beginning of the plastic phase.

```
x2 = x_pl(t2)
v2 = v_pl(t2)
dp = x2-x1
```

To plot the first two phases of the response we have to be careful with time ranges, but it;s relatively simple. In blue the elastic, loading phase, in green the plastic phase.

```
t_1 = linspace(0,t1,1001)
t_2 = linspace(0,t2,1001)
plot(t_1,x_el0(t_1), t_2+t1, x_pl(t_2)) ; grid() ;
```

Now for the elastic unloading, the elastic force is now $f_\text{S}=k(x-\Delta_\text{pl})$ so that the equation of motion can be written

$m\ddot x + c\dot x + kx = k\Delta_\text{pl}$

whose general integral is

$x(t) = \exp(-\zeta\omega_n t)\left(A\cos(\omega_\text{D}t)+B\sin(\omega_\text{D}\right)+\Delta_\text{pl}.$

Next, by the initial conditions we have the constants of integration, and finally the definition of the displacements and velocities in the unloading phase.

```
# x(t3) = exp A cos + exp B sin + dp ; v(t3) = ...
# x3(0) = A + dp = x2 ; v3(0) = B wd - A z wn = v2
A = x2-dp
B = (v2+A*zeta*wn)/wd

def x_el1(t, A=A, B=B): return exp(-zeta*wn*t)*(A*cos(wd*t)+B*sin(wd*t))+dp
def v_el1(t, A=A, B=B): return exp(-zeta*wn*t)*((B*wd-A*wn*zeta)*cos(wd*t)-(B*wn*zeta+A*wd)*sin(wd*t))
```

Eventually, we can plot the free vibrations of our elastic-perfectly plastic system... note that I've stretched the time axis to let the response damp out almost completely, so that you can readily appreciate that the system is not oscillating around the initial equilibrium position, i.e., zero, but is asymptotically reaching a ***new*** position of equilibrium, $\Delta_\text{pl}$.

```
figsize(18,6)
t_3 = linspace(0,8-t1-t2,2001)
plot(t_1,x_el0(t_1), t_2+t1, x_pl(t_2), t1+t2+t_3, x_el1(t_3)) ; grid() ;
```

```

```


```python
import sympy

def elasticity(P1,P2,Q1,Q2):
    return ((Q2-Q1)/((Q2+Q1)/2))/((P2-P1)/((P2+P1)/2))

p = sympy.Symbol("Price")
DemandEquation = 11-p

for x in range(1,10):
    print(abs(elasticity(x,x+1,DemandEquation.subs(p,x),DemandEquation.subs(p,x+1))))
```

    0.157894736842105
    0.294117647058824
    0.466666666666667
    0.692307692307692
    1.00000000000000
    1.44444444444444
    2.14285714285714
    3.40000000000000
    6.33333333333333

```python
[x for x in range(1,11)]
```

    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

```python
demandQ = [DemandEquation.subs(p,x) for x in range(0,12)]
prices = [x for x in range(0,12)]
```

```python
import matplotlib.pyplot as plt
import matplotlib.patches as patches
def drawRevenue(P,Q):
    rect = patches.Rectangle((0,0),Q,P,linewidth=1,facecolor="blue")
    currentAxis = plt.gca()
    currentAxis.add_patch(rect)
    print("The revenue of the graph below is "+str(P*Q))
```

```python
for x in range(1,11):
    quantity = DemandEquation.subs(p,x)
    elast = abs(elasticity(x,x+1,DemandEquation.subs(p,x),DemandEquation.subs(p,x+1)))
    plt.plot(demandQ,prices)
    drawRevenue(x,quantity)
    print("The elasticity at this point is "+str(elast))
    plt.show()
```

```python
for x in range(1,11):
    quantity = DemandEquation.subs(p,x)
    elast = abs(elasticity(x,x+1,DemandEquation.subs(p,x),DemandEquation.subs(p,x+1)))
    revenue = quantity*x
    print("At price "+str(x)+" elasticity is "+str(elast)+" and the revenue is "+str(revenue)+".")
```

    At price 1 elasticity is 0.157894736842105 and the revenue is 10.
    At price 2 elasticity is 0.294117647058824 and the revenue is 18.
    At price 3 elasticity is 0.466666666666667 and the revenue is 24.
    At price 4 elasticity is 0.692307692307692 and the revenue is 28.
    At price 5 elasticity is 1.00000000000000 and the revenue is 30.
    At price 6 elasticity is 1.44444444444444 and the revenue is 30.
    At price 7 elasticity is 2.14285714285714 and the revenue is 28.
    At price 8 elasticity is 3.40000000000000 and the revenue is 24.
    At price 9 elasticity is 6.33333333333333 and the revenue is 18.
    At price 10 elasticity is 21.0000000000000 and the revenue is 10.

```python
elast = [abs(elasticity(x,x+1,DemandEquation.subs(p,x),DemandEquation.subs(p,x+1))) for x in range(1,11)]
revenue = [DemandEquation.subs(p,x)*x for x in range(1,11)]
prices = [x for x in range(1,11)]
plt.plot(prices,revenue)
plt.show()
plt.plot(elast,revenue)
plt.show()
```

```python
import sympy
import matplotlib.pyplot as plt
import matplotlib.patches as patches
p = sympy.Symbol("p")

def supplyShift(demandEquation,supplyEquation1,supplyEquation2,priceStart,priceEnd):
    prices = []
    demandQ = []
    supplyQ1 = []
    supplyQ2 = []
    for price in range(priceStart,priceEnd+1):
        prices += [price]
        demandQ += [demandEquation.subs(p,price)]
        supplyQ1 += [supplyEquation1.subs(p,price)]
        supplyQ2 += [supplyEquation2.subs(p,price)]

    equilibriumP1 = sympy.solve(demandEquation-supplyEquation1)[0]
    equilibriumQ1 = demandEquation.subs(p,equilibriumP1)
    equilibriumP2 = sympy.solve(demandEquation-supplyEquation2)[0]
    equilibriumQ2 = demandEquation.subs(p,equilibriumP2)

    plt.plot(demandQ,prices)
    plt.plot(supplyQ1,prices)
    plt.plot(supplyQ2,prices)
    plt.legend(["Old Demand","Old Supply","New Supply"])
    plt.plot(equilibriumQ1,equilibriumP1, 'ro')
    plt.plot(equilibriumQ2,equilibriumP2, 'ro')
    plt.xlabel("Supply and Demand Quantity")
    plt.ylabel("Price")

    rect1 = patches.Rectangle((0,0),equilibriumQ1,equilibriumP1,linewidth=1,color="blue")
    rect2 = patches.Rectangle((0,0),equilibriumQ2,equilibriumP2,linewidth=1,color="red")
    currentAxis = plt.gca()
    currentAxis.add_patch(rect1)
    currentAxis.add_patch(rect2)

    plt.show()
    print("The old equilibrium price is "+str(equilibriumP1)+" and old equilibrium quantity is "+str(equilibriumQ1)+".")
    print("The new equilibrium price is "+str(equilibriumP2)+" and new equilibrium quantity is "+str(equilibriumQ2)+".")
    print("The price shifted "+str(equilibriumP2-equilibriumP1)+" and the quantity shifted "+str(equilibriumQ2-equilibriumQ1)+".")
    print("")
    print("The old revenue was "+str(equilibriumQ1*equilibriumP1))
    print("The new revenue is "+str(equilibriumQ2*equilibriumP2))
    print("The revenue shifted by " +str(equilibriumQ2*equilibriumP2-equilibriumQ1*equilibriumP1))
supplyShift(10-p,p,2*p,0,10)
```

FinanceAndPython.com-EconomicFoundations-master/8 Elasticity and Revenue.ipynb


(algebra_tutorial)=
# Tutorial

To demonstrate the ways in which we can use a computer to assist with Algebra we
will solve the following two problems:

```{admonition} Problem

1. Rationalise the denominator of $\frac{1}{\sqrt{2} + 1}$
2. Consider the quadratic: $f(x)=2x ^ 2 + x + 1$:
    1. Calculate the discriminant of the quadratic equation $2x ^ 2 + x + 1 =
     0$. What does this tell us about the solutions to the equation? What
     does this tell us about the graph of $f(x)$?
    2. By completing the square, show that the minimum point of $f(x)$ is
     $\left(-\frac{1}{4}, \frac{7}{8}\right)$
```

To do this we will be using a specific collections of tools available in Python.
Often specific sets of tools are separated in to things called **libraries**. We
start by telling Python that we want to use the specific library for **symbolic
mathematics**:

```python
import sympy
```

This allows us to solve the first part of the question. First we create a
variable `expression` and **assign** it a value of $\frac{1}{\sqrt{2} + 1}$.

```python
expression = 1 / (sympy.sqrt(2) + 1)
expression
```

$\displaystyle \frac{1}{1 + \sqrt{2}}$

```{attention}
This is not what would happen if we plugged the above in to a basic calculator,
it would instead give us a value of:
```

```python
float(expression)
```

    0.41421356237309503

The `sympy` library has a diverse set of tools available, one of which is to
algorithmically attempt to simplify an expression. Here is how we do that:

```python
sympy.simplify(expression)
```

$\displaystyle -1 + \sqrt{2}$

This implies that:

$$
    \frac{1}{\sqrt{2} + 1} = -1 + \sqrt{2}
$$

If we multiply both sides by ${\sqrt{2} + 1}$ we get:

$$
    1=\frac{1}{\sqrt{2} + 1}\times \left(\sqrt{2} + 1\right) = \left(-1 + \sqrt{2}\right)\times \left(\sqrt{2} + 1\right)
$$

The `sympy.simplify` command did not give us much insight in to what happened
but we can further confirm that above manipulation by expanding $\left(-1 +
\sqrt{2}\right)\times \left(\sqrt{2} + 1\right)$.

Here is how we do that:

```python
sympy.expand((-1 + sympy.sqrt(2)) * (1 + sympy.sqrt(2)))
```

$\displaystyle 1$

We see that `sympy` allows us to carry out basic expression manipulation. We
will now consider the second part of the question:

```{admonition} Problem

2. Consider the quadratic: $f(x)=2x ^ 2 + x + 1$:
  1. Calculate the discriminant of the quadratic equation $2x ^ 2 + x + 1 =
     0$. What does this tell us about the solutions to the equation? What
     does this tell us about the graph of $f(x)$?
  2. By completing the square, show that the minimum point of $f(x)$ is
     $\left(-\frac{1}{4}, \frac{7}{8}\right)$
```

We will start by reassigning the value of the variable `expression` to be the
expression: $2x ^ 2 + x + 1$.

```python
x = sympy.Symbol("x")
expression = 2 * x ** 2 + x + 1
expression
```

$\displaystyle 2 x^{2} + x + 1$

```{tip}
**Recall** that the `**` symbol is how we communicate exponentiation.
```

```{attention}
We start by communicating to the code that `x` is going to be a symbolic variable.
```

We can immediately use this to compute the discriminant:

```python
sympy.discriminant(expression)
```

$\displaystyle -7$

Now we can complement this with our mathematical knowledge: if a quadratic has a
negative discriminant then it does not have any roots and all the values are of
the same sign as the coefficient of $x ^ 2 $. Which in this case is $2>0$.

We can confirm this by directly creating the equation. We do this by creating a
variable `equation` and assigning it the equation which has a `lhs` and a `rhs`:

```python
equation = sympy.Eq(lhs=expression, rhs=0)
equation
```

$\displaystyle 2 x^{2} + x + 1 = 0$

and asking `sympy` to solve it:

```python
sympy.solveset(equation)
```

$\displaystyle \left\{- \frac{1}{4} - \frac{\sqrt{7} i}{4}, - \frac{1}{4} + \frac{\sqrt{7} i}{4}\right\}$

Indeed the only solutions are imaginary numbers: the graph of $f(x)$ is thus a convex parabola.

Let us know complete the square so that we can write:

$$
    f(x) = a (x - b) ^ 2 + c
$$

for some values of $a, b, c$.

First let us create variables that have those 3 constants as value but also create a variable `completed_square` and assign it the general expression:

```python
a, b, c = sympy.Symbol("a"), sympy.Symbol("b"), sympy.Symbol("c")
completed_square = a * (x - b) ** 2 + c
completed_square
```

$\displaystyle a \left(- b + x\right)^{2} + c$

We can expand this:

```python
sympy.expand(completed_square)
```

$\displaystyle a b^{2} - 2 a b x + a x^{2} + c$

We will now use `sympy` to solve the various equations that arise from comparing
the coefficients of:

$$
    f(x) = 2x ^2 + x + 1
$$

with the completed square.

First, we see that the coefficient of $x ^ 2$ gives us an equation:

$$
    a = 2
$$

For completeness we will write the code that solves this trivial equation:

```python
equation = sympy.Eq(a, 2)
sympy.solveset(equation, a)
```

$\displaystyle \left\{2\right\}$

We will now substitute this value of $a$ in to the completed square and update the variable with the new value:

```python
completed_square = completed_square.subs({a: 2})
completed_square
```

$\displaystyle c + 2 \left(- b + x\right)^{2}$

```{attention}
The different types of brackets being used there: both `()` and `{}`. This is
important and has specific meaning in Python which we will cover soon.
```

Now let us look at the expression with our two remaining constants:

```python
sympy.expand(completed_square)
```

$\displaystyle 2 b^{2} - 4 b x + c + 2 x^{2}$

Comparing the coefficients of $x$ we have the equation:

$$
  - 4 b = 1
$$

```python
equation = sympy.Eq(-4 * b, 1)
sympy.solveset(equation, b)
```

$\displaystyle \left\{- \frac{1}{4}\right\}$

We will now substitute this value of $b$ back in to our expression.

```{attention}
We make a point to tell sympy to treat $1 / 4$ symbolically and to not
calculate the numeric value:
```

```python
completed_square = completed_square.subs({b: -1 / sympy.S(4)})
completed_square
```

$\displaystyle c + 2 \left(x + \frac{1}{4}\right)^{2}$

Expanding this to see our expression with the one remaining constant gives:

```python
sympy.expand(completed_square)
```

$\displaystyle c + 2 x^{2} + x + \frac{1}{8}$

We have a final equation for the constant term:

$$
    c + 1 / 8 = 1
$$

We will use sympy to find the value of $c$:

```python
sympy.solveset(sympy.Eq(c + sympy.S(1) / 8, 1), c)
```

$\displaystyle \left\{\frac{7}{8}\right\}$

As before we will substitute that in and update the value of `completed_square`:

```python
completed_square = completed_square.subs({c: 7 / sympy.S(8)})
completed_square
```

$\displaystyle 2 \left(x + \frac{1}{4}\right)^{2} + \frac{7}{8}$

Using this we can see that the there are indeed no values of $x$ which give
negative values of $f(x)$ as $f(x)$ is a square added to a constant.

The minimum is when $x=-1/4$ which gives: $f(-1/4)=7/8$:

```python
completed_square.subs({x: -1 / sympy.S(4)})
```

$\displaystyle \frac{7}{8}$

```{important}
In this tutorial we have

- Created symbolic expressions.
- Obtained approximate values for numerical symbolic expressions.
- Expanded and simplified symbolic expressions.
- Created symbolic equations.
- Solve symbolic equations.
- Substituted values in to symbolic expressions.
```


###### Content under Creative Commons Attribution license CC-BY 4.0, code under BSD 3-Clause License © 2019 Lorena A. Barba, Tingyu Wang

# Eigenvectors for the win

In this third lesson of the module "Land on Vector Spaces," for learning practical linear algebra with Python, we focus all our attention on eigenvectors and eigenvalues. We do so emphasizing a geometric point of view to help develop a lasting understanding of the concepts, and computing with Python instead of tedious manual techniques. This way, we can quickly get to some applications and realize how important and useful they are.

The eigenvectors of a matrix are the special vectors that don't change direction after a linear transformation. _They don't get thrown off their span!_ This is so cool. The eigenvalues represent the scaling on the eigenvectors by the transformation. And that is  a surprisingly useful fact.
Let's get eigen-cracking.

As always, start this lesson by loading your favorite Python libraries, and our custom plotting functions to help us out.

```python
import numpy
%matplotlib inline
from matplotlib import pyplot
```

```python
import sys
sys.path.append('../scripts/')

# Our helper, with the functions:
# plot_vector, plot_linear_transformation, plot_linear_transformations
from plot_helper import *
```

## Geometry of eigendecomposition

When you know the eigenvectors and eigenvalues, you know the matrix. Many applications of linear algebra benefit from expressing a matrix using its eigenvectors and eigenvalues (it often makes computations easier).
This is called _eigendecomposition_—matrix decomposition is a central topic in linear algebra, and particularly important in applications.

### Eigenvectors revisited

In the previous lesson, we saw that a unit circle, by a 2D linear transformation, lands on an ellipse. The semi-major and semi-minor axes of the ellipse were in the direction of the *eigenvectors* of the transformation matrix. Now, you should know that this only happens wiht _symmetric_ matrices, but let's revisit this idea.

We'll work with the matrix $\,A = \begin{bmatrix} 2 & 1 \\ 1 & 2 \end{bmatrix}$.

```python
A = numpy.array([[2,1], [1,2]])
print(A)
plot_linear_transformation(A)
```

As in the previous lesson, plot a set of vectors of unit length (whose heads trace the unit circle), visualize the transformed vectors, then compute the length of the semi-major and semi-minor axes of the ellipse (the norm of the longest and shortest vectors in our set).

```python
alpha = numpy.linspace(0, 2*numpy.pi, 41)
vectors = list(zip(numpy.cos(alpha), numpy.sin(alpha)))
newvectors = []
for i in range(len(vectors)):
    newvectors.append(A.dot(numpy.array(vectors[i])))

plot_vector(vectors)
```

```python
plot_vector(newvectors)
```

```python
lengths = []
for i in range(len(newvectors)):
    lengths.append(numpy.linalg.norm(newvectors[i]))
semi_major = max(lengths)
print('Semi-major axis {:.1f}'.format(semi_major))
semi_minor = min(lengths)
print('Semi-minor axis {:.1f}'.format(semi_minor))

u1 = numpy.array([semi_major/numpy.sqrt(2), semi_major/numpy.sqrt(2)])
u2 = numpy.array([-semi_minor/numpy.sqrt(2), semi_minor/numpy.sqrt(2)])
```

    Semi-major axis 3.0
    Semi-minor axis 1.0

### A scaling transformation

OK, cool. In our first lesson, we saw some special transformations: _rotation_, _shear_, and _scaling_.
Looking at the effect of the matrix transformation $A$ on the unit circle, we might imagine obtaining the same effect by first scaling the unit vectors—stretching $\mathbf{i}$ to $3\times$ its length and leaving $\mathbf{j}$ with length $1$—and then rotating by 45 degrees counter-clockwise.
We have also learned that applying linear transformations in sequence like this amounts to matrix multiplication.

Let's try it. We first define the scaling transformation $S$, and apply it to the vectors mapping the unit circle.

```python
S = numpy.array([[3,0], [0,1]])
print(S)
```

    [[3 0]
     [0 1]]

```python
ellipse = []
for i in range(len(vectors)):
    ellipse.append(S.dot(numpy.array(vectors[i])))

plot_vector(ellipse)
```

### A rotation transformation

We figured out the matrix for a 90-degree rotation in our first lesson. But how do you rotate by any angle? You never have to memorize the "formula" for a rotation matrix. Just think about where the unit vectors land. Look at the figure below, and follow along on a piece of paper if you need to.

#### Rotation of unit vectors by an angle $\theta$ to the left.

$$
\mathbf{i} = \begin{bmatrix} 1 \\ 0 \end{bmatrix}  \Rightarrow  \begin{bmatrix} \cos{\theta} \\ \sin{\theta} \end{bmatrix} \\
\mathbf{j} = \begin{bmatrix} 0 \\ 1 \end{bmatrix}  \Rightarrow  \begin{bmatrix} -\sin{\theta} \\ \cos{\theta} \end{bmatrix}
$$

You now can build the rotation matrix using the column vectors where each unit vector lands.

$$R = \begin{bmatrix} \cos{\theta} & -\sin{\theta} \\ \sin{\theta} & \cos{\theta} \end{bmatrix}$$

Great. Let's define a matrix $R$ that rotates vectors by 45 degrees.

```python
theta = numpy.pi/4
R = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
                 [numpy.sin(theta), numpy.cos(theta)]])
```

We can apply this rotation now to the `ellipse` vectors, and plot the result.

```python
rotated = []
for i in range(len(vectors)):
    rotated.append(R.dot(numpy.array(ellipse[i])))

plot_vector(rotated)
```

### Composition of the transformations

It certainly _looks_ like we recovered the picture we obtained originally when applying the transformation $A$ to all our vectors on the unit circle.

But have a look at the two transformations—the scaling $S$ and the rotation $R$—applied in sequence:

```python
plot_linear_transformations(S,R)
```

Observe carefully the plot above. The scaling did stretch the basis vector $\mathbf{i}$ by $3\times$ its original length. It also left the basis vector $\mathbf{j}$ with its length equal to $1$. But something looks _really_ off after the second transformation.

**What went wrong?** Let's investigate.

We know from the discussion in the previous lesson that the vector that lands on the ellipse's semi-major axis doesn't change direction. It's _not_ the basis vector $\mathbf{i}$ that lands there, it's the vector $\mathbf{v}_1$ that satisfies:

$$ A \mathbf{v}_1 = s_1 \mathbf{v}_1 $$

Recalling the process we followed in the previous lesson, we find that vector, and plot it together with its transformed version (also called its _image_):

```python
A_inv = numpy.linalg.inv(A)
v1 = A_inv.dot(u1)
plot_vector([u1,v1])
```

Right. The unit vector that was aligned with the 45-degree line got transformed onto the semi-major axis of the ellipse, without being rotated. This is the effect of the matrix $A$ on $\mathbf{v}_1$: _it is just scaled_.

Now, let's look at the sequence of transformations $S$ and $R$ applied to $\mathbf{v}_1$. We apply the transformations by matrix-vector multiplication, and in the second step, we use composition of transformations.

```python
plot_vector([v1, S.dot(v1)])
```

```python
plot_vector([S.dot(v1),R.dot(S.dot(v1))])
```

That is definitely _not_ what we expected. Oh well. It seemed like a good idea at the time, but the scaling $S$ and the rotation $R$ applied in sequence are _not_ equivalent to the transformation $A$.
Our visual intuition was not able to get the whole picture.

### Complete the composition

OK. This will blow your mind… to get the same transformation as $A$ we had to _first_ rotate 45 degrees to the right (which leaves the plot of our circle unchanged even though the vectors rotated), _then_ scale, and finally rotate 45 degrees to the left.

We will look at this sequence of transformations via matrix multiplicaton. But first note that a rotation by a negative angle $\theta$ is achieved by the matrix:

$$R^T = \begin{bmatrix} \cos{\theta} & \sin{\theta} \\ -\sin{\theta} & \cos{\theta} \end{bmatrix}$$

Check using a piece of paper that the columns of this matrix make sense for a negative angle $\theta$, and notice that it is the **transpose** of $R$, i.e., swaps rows and columns.
You can compute the transpose of a matrix in Python using [`numpy.matrix.transpose()`](https://docs.scipy.org/doc/numpy/reference/generated/numpy.matrix.transpose.html). Try it with some sample matrix, and print it with its transpose to visualize what happens.

```python
B = numpy.array([[1,99], [2,100]])
print(B)
print(B.transpose())
```

    [[  1  99]
     [  2 100]]
    [[  1   2]
     [ 99 100]]

Now look at the result of the matrix multiplication (equivalent to the transformations in sequence, right to left):

```python
R @ S @ R.transpose()
```

    array([[2., 1.],
           [1., 2.]])

That's certainly the same as $A$!

```python
print(A)
```

    [[2 1]
     [1 2]]

```python
plot_linear_transformation(R @ S @ R.transpose())
```

We have some explaining to do. Let's visualize the transformation $R^T$, adding to our plot the unit vectors that were aligned with the eigenvectors, $\mathbf{v}_1$ and $\mathbf{v}_2$. You see that they land on the coordinate axes after the transformation.

```python
v2 = A_inv.dot(u2)
plot_linear_transformation(R.transpose(), v1, v2)
```

Now let's visualize applying  the scaling transformation to these vectors, and applying the rotation matrix $R$ after that.

```python
e1 = R.transpose().dot(v1)
e2 = R.transpose().dot(v2)

plot_linear_transformation(S, e1, e2)
```

```python
plot_linear_transformation(R, S.dot(e1), S.dot(e2))
```

Satisfied? The vectors $\mathbf{v}_1$ and $\mathbf{v}_2$ are first rotated to land on the axes, are then scaled, and are finally rotated back to their original direction. This has the same effect as the transformation $A$. In other words:

$$ A = R\, S\, R^T
$$

The equivalence above shows an **eigendecomposition** of the matrix $A$.
The scaling matrix $S$ has the *eigenvalues* along the diagonal, and the rotation matrix $R$ has the *eigenvectors* as columns. Let's check:

```python
print(R[:,0])
print(v1)
```

    [0.70710678 0.70710678]
    [0.70710678 0.70710678]

```python
print(R[:,1])
print(v2)
```

    [-0.70710678  0.70710678]
    [-0.70710678  0.70710678]

### Symmetric matrices, orthogonal eigenvectors

In our example, we figured out that the matrix that undoes the effect of the positive rotation $R$ is its transpose, $R^T$. But in general, the matrix that has the effect of undoing a transformation is the _inverse_ of this matrix. In fact, $R$ is special, because

$$ R^T = R^{-1} $$

##### Definition:

> A matrix $R$ whose transpose is equal to its inverse is called **orthogonal**.

The columns of an orthogonal matrix are orthogonal vectors of unit length (a.k.a., _orthonormal_). For our example, it means that the _eigenvectors of $A$ are orthogonal_.
This always happens with _symmetric matrices_.

```python
print(R.transpose())
```

    [[ 0.70710678  0.70710678]
     [-0.70710678  0.70710678]]

```python
print(numpy.linalg.inv(R))
```

    [[ 0.70710678  0.70710678]
     [-0.70710678  0.70710678]]

You can check visually that $R^T$ is equal to $R^{-1}$—but if we try a logical expression, it will give `False` because of floating-point errors. To check for the symmetry of $A$, we can use a logical operation:

```python
A == A.transpose()
```

    array([[ True,  True],
           [ True,  True]])

And to confirm that the two eigenvectors are orthogonal, we take the dot product, which is zero for perpendicular vectors:

```python
v1.dot(v2)
```

    0.0

##### Key idea:

> A _symmetric_ matrix, $\,A = A^T$, has _orthogonal_ eigenvectors, and can be decomposed as $A = R\, S\, R^T$, where $R$ has the eigenvectors as columns and $S$ is the diagonal matrix of eigenvalues.

## Eigendecomposition in general

Up to this point, we've been playing with a symmetric matrix. The geometric insight we developed showed us that the eigenvectors landed onto the semi-major and semi-minor axis of the ellipse coming from the unit circle. But most matrices are not symmetric!
Let's work the general case. The eigenvectors of $A$ get scaled after the transformation, i.e., land on their span:

$$
\begin{align*}
  A \mathbf{v_1} = s_1 \mathbf{v_1} \\
  A \mathbf{v_2} = s_2 \mathbf{v_2}
\end{align*}
$$

The two left-hand sides are matrix-vector multiplications, each giving a vector as result. The two right-hand sides are scalings of a column vector (also resulting in vectors). We would like to combine the two equations together into a matrix equation, leading to the eigendecomposition of $A$.

Our first idea is this: matrix-matrix multiplication is the same as applying a transformation via the matrix on the left, to the columns of the matrix on the right. So if we make a matrix with the two eigenvectors as columns, and multiply it with $A$:

$$ A \begin{bmatrix}
    \mathbf{v_1} & \mathbf{v_2}
    \end{bmatrix}
    = \begin{bmatrix}
    A\mathbf{v_1} & A\mathbf{v_2}
    \end{bmatrix}
$$

We can now re-write the eigenvector equations into one matrix equation by matching columns:

$$ A \begin{bmatrix}
    \mathbf{v_1} & \mathbf{v_2}
    \end{bmatrix}
    = \begin{bmatrix}
    s_1\mathbf{v_1} & s_2\mathbf{v_2}
    \end{bmatrix}
$$

Using $C$ to denote the matrix of eigenvectors, the left-hand side of this matrix equation is $A\, C$.  We can express the right-hand side of the combined eigenvector equations also using $C$, by working with each column:

$$ s_1 \mathbf{v_1} = \begin{bmatrix}
    \mathbf{v_1} & \mathbf{v_2} \end{bmatrix} \,
    \begin{bmatrix}
    s_1 \\
    0
    \end{bmatrix}
$$

$$ s_2 \mathbf{v_2} = \begin{bmatrix}
    \mathbf{v_1} & \mathbf{v_2} \end{bmatrix} \,
    \begin{bmatrix}
    0 \\
    s_2
    \end{bmatrix}
$$

Matchng the columns, and applying our first idea for matrix-matrix multiplication:

$$  \begin{bmatrix}
    s_1\mathbf{v_1} & s_2\mathbf{v_2}
    \end{bmatrix}
    = C\,
    \begin{bmatrix}
    s_1 & 0 \\
    0 & s_2
    \end{bmatrix}
$$

Using $D$ to denote the diagonal matrix of eigenvalues, this all comes together as:

$$
  A\, C = C\, D
$$

If we right-multiply by $C^{-1}$ on both sides:

$$
  A = C\, D\, C^{-1}
$$

A matrix that can be decomposed in this way is called **diagonalizable**.
Multiplying on the right by $C$ and on the left by $C^{-1}$:

$$
  C^{-1} A\, C = D
$$

If you go back to the previous lesson, you will find an expression that looks like this for applying a known transformation to a vector in a new basis. Review that section if you need to.

Applying the transformation $A$ to a vector in standard basis, $\mathbf{x}$, is:

$$
  A\, \mathbf{x} = C\, D\, C^{-1}\mathbf{x}
$$

Viewing $C$ as a change of basis, the expression on the right means we change $\mathbf{x}$ to a new basis of eigenvectors, apply a scaling by the eigenvalues in the new coordinate system, and change back to the standard basis. The effect is the same as the transformation $A$: the matrices $A$ and $D$ are called **similar**.

##### Key idea:

> Similar matrices have the same effect, via a change of basis.

Now let's visualize the eigendecomposition of a non-symmetric matrix $A = \begin{bmatrix} 1 & 0 \\ 1 & 3 \end{bmatrix}$. The red unit circle below represents a bunch of vectors of different angles whose tails sit at the origin and whose heads land on the circle. Each point on this circle corresponds to some vector $\mathbf{x}$. The second subplot shows the change from the standard basis $\mathbf{i}$ and $\mathbf{j}$ to the new basis $\mathbf{a}$ and $\mathbf{b}$. Notice that the red circle representing $\mathbf{x}$ does not change, however, the vector $\mathbf{x}$'s new coordinates are now $C^{-1}\mathbf{x}$. The third subplot demonstrates the stretching effect coming from the diagonal matrix $D$ in the new basis. The transformed vector's coordinates are $DC^{-1}\mathbf{x}$ in the new basis. Finally, we change back to the standard basis by multiplying $C$ as shown in the subplot 4.

```python
A = numpy.array([[1,0], [1,3]])
plot_eigen(A)
```

##### Challenge:
> What is the relation between the new basis $\mathbf{a}$, $\mathbf{b}$ and the matrix of eigenvectors $C$?

## Compute eigenthings in Python

You can compute the eigenvalues and eigenvectors of a matrix using [`numpy.linalg.eig()`](https://docs.scipy.org/doc/numpy/reference/generated/numpy.linalg.eig.html). It returns a tuple: its first element is an array with the eigenvalues, and its second element is a 2D array  where each column is an eigenvector.

```python
numpy.linalg.eig(A)[0]
```

    array([3., 1.])

```python
numpy.linalg.eig(A)[1]
```

    array([[ 0.        ,  0.89442719],
           [ 1.        , -0.4472136 ]])

```python
# display each eigenvalue with the corresponding eigenvector, side by side
eigenvalues, eigenvectors = numpy.linalg.eig(A)

for eigenvalue, eigenvector in zip(eigenvalues, eigenvectors.T):
    print(eigenvalue, eigenvector)
```

    3.0 [0. 1.]
    1.0 [ 0.89442719 -0.4472136 ]

In the `for` statement above, each iteration picks an eigenvalue, and the corresponding eigenvector. Note that when iterating over a 2D array, the natural order advances over the first dimension: the rows; to get the columns, we apply the transpose of the `eigenvectors` matrix. Here, we used the [`numpy.ndarray.T`](https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.T.html), which has the same effect as `.transpose`.

To create the diagonal matrix of eigenvalues, use [`numpy.diag()`](https://docs.scipy.org/doc/numpy/reference/generated/numpy.diag.html): if you give it a 1D array, it returns a 2D array with the elements of the input array in the diagonal.

The eigendecomposition is shown below:

```python
C = eigenvectors
A_decomp = C @ numpy.diag(eigenvalues) @ numpy.linalg.inv(C)
print(A_decomp)
```

    [[1. 0.]
     [1. 3.]]

Another way to compute all the eigenthings is to use **SymPy**: the Python library for symbolic computations (a.k.a., computer algebra system). SymPy has a [`Matrix`](https://docs.sympy.org/latest/tutorial/matrices.html) data type with many advanced methods.

You will create a `Matrix` from a list of row vectors, and then use the `diagonalize()` method to obtain the matrix of eigenvectors, and the diagonal matrix of eigenvalues.
SymPy can display beautiful symbolic mathematics. Check it out.

```python
import sympy

sympy.init_printing(use_latex = 'mathjax') #configures the display of mathematics
```

```python
A = sympy.Matrix([[2,1], [1,2]])
A
```

$$\left[\begin{matrix}2 & 1\\1 & 2\end{matrix}\right]$$

```python
A == A.transpose()
```

    True

```python
C, D = A.diagonalize()
D
```

$$\left[\begin{matrix}1 & 0\\0 & 3\end{matrix}\right]$$

With SymPy, you can also get just the eigenvalues using the [`eigenvals()`](https://docs.sympy.org/latest/tutorial/matrices.html#eigenvalues-eigenvectors-and-diagonalization) method. Its output will be in the form $\{s_1:m_1, s_2:m_2\}$, where $s_1$, $s_2$ are the eigenvalues of the matrix, and $m_1$, $m_2$ are the _multiplicities_. A matrix can have (multiply) repeated eigenvalues sometimes. In the case of our matrix $A$, both eigenvalues are unique (i.e., have multiplicity $1$).

```python
A.eigenvals()
```

$$\left \{ 1 : 1, \quad 3 : 1\right \}$$

But think of the identity matrix: it has $1$ along the diagonal, and the two eigenvalues are the same, $1$. We can create an identity matrix in SymPy using `eye(n)`, with `n` indicating the dimension.

```python
sympy.eye(2).eigenvals()
```

$$\left \{ 1 : 2\right \}$$

If we try with the _shear_ matrix from our first lesson, we see that it also has a repeated $1$ eigenvalue.

```python
shear = sympy.Matrix([[1,1], [0,1]])
shear
```

$$\left[\begin{matrix}1 & 1\\0 & 1\end{matrix}\right]$$

```python
shear.eigenvals()
```

$$\left \{ 1 : 2\right \}$$

Let's have a look back at the equation for the eigenvectors—on which the effect of a matrix $A$ is just to scale them—and rearrange things a bit:

$$ \begin{align*}
  A\, \mathbf{v} &= s\, \mathbf{v}\\
  A\, \mathbf{v} - s\, \mathbf{v} &= \mathbf{0} \\
  (A - s\, I) \mathbf{v} &= \mathbf{0}
\end{align*}
$$

The last form represents a homogeneous linear system, whose solutions are the _nullspace_ of $(A-s\,I)$, as we saw in our second lesson. We can compute the nullspace using SymPy; let's try it with the shear matrix:

```python
(shear - sympy.eye(2)).nullspace()
```

$$\left [ \left[\begin{matrix}1\\0\end{matrix}\right]\right ]$$

The [`nullspace()`](https://docs.sympy.org/latest/tutorial/matrices.html#nullspace) method of SymPy matrices returns a list of column vectors that span the nullspace of the matrix. In the case of the shear matrix, only one column vector is returned, which means  its nullspace is a line.

The shear matrix has one repeated eigenvalue, and a single eigenvector: we cannot build a change of basis with its eigenvectors, which means it's _not diagonalizable_.

If you tried to use `diagonalize()` on the shear matrix, you would get an error message:
```Pyhon
MatrixError: Matrix is not diagonalizable
```

## Applications

### Eigenvalues in ecology

A neat application of linear algebra is _matrix population models_, used broadly in ecology and demographics. One type of model considers the age distribution of fertility and survival in an animal or plant population. For example, an insect population may have different probability of survival depending on the stage of development, and only be able to reproduce in an adult stage.

Here's an example, from [$\S$5.6](https://textbooks.math.gatech.edu/ila/stochastic-matrices.html) of the open book _"Interactive Linear Algebra"_ [1].
We take three age groups in a population of rabbits. Of the newborn rabbits, 50% survive their first year. The survival of the second age group is also 50%, and the third age group all dies off at the end of the year. The annual fertility of each age group is 0, 6, and 8 rabbits, respectively.

A population vector $\mathbf{p}=(p_1, p_2, p_3)$ contains the abundance of rabbits in each age group. We can use the fertility and survival of each age group to predict the abundance of each group after one year:

$$\begin{align*}
  p_1^{n+1} &=& 0\,p_1^n + 6\,p_2^n + 8\,p_3^n \\
  p_2^{n+1} &=& 0.5\,p_1^n + 0\,p_2^n + 0\,p_3^n \\
  p_3^{n+1} &=& 0\,p_1^n + 0.5\,p_2^n + 0\,p_3^n
\end{align*}
$$

The superscript $n$ refers to the year. The coefficient matrix of this system of equations is:

$$ L = \begin{bmatrix} 0 & 6 & 8 \\
                       0.5 & 0 & 0 \\
                       0 & 0.5 & 0 \end{bmatrix}
$$

This matrix represents the _age transition_ year on year. Multiply this matrix by the population vector to get the age-structured population on the next year. Let's write a bit of code to see the population in each age group grow over 10 years.

```python
L = numpy.array([[0,6,8], [0.5,0,0], [0,0.5,0]])
L
```

    array([[0. , 6. , 8. ],
           [0.5, 0. , 0. ],
           [0. , 0.5, 0. ]])

```python
p = numpy.array([25, 10, 5]) # initial age-structured population
```

```python
N = 10
pn = numpy.zeros((N, len(p)))
pn[0,:] = p.copy()
for i in range(1,N):
    pn[i,:] = L.dot(pn[i-1,:])
```

```python
pyplot.figure(figsize=(4,1))
pyplot.plot(pn[:,0],)
pyplot.plot(pn[:,1],)
pyplot.plot(pn[:,2],)
pyplot.xlabel('Years')
pyplot.title('Population in each age group');
```

Nothing to see here: the population in each age group grows over time. But one question ecologists may need to answer is whether the species reaches a stable growth pattern, and what is the _stable age distribution_. That occurs if the percentage in each age group remains the same, i.e., $\mathbf{p}^{n+1}$ is a multiple of $\mathbf{p}^n$:

$$ \mathbf{p}^{n+1} = L\, \mathbf{p}^n = r\, \mathbf{p}^n $$

We have on the right an eigenvalue problem, and the stable age distribution is an eigenvector of the age transition matrix.

A classic example in this field is the hypothetical beetle population of Bernadelli [2], also separated in three age groups, and with age transition matrix:

$$ T = \begin{bmatrix} 0 & 0 & 6 \\
                       1/2 & 0 & 0 \\
                       0 & 1/3 & 0 \end{bmatrix}
$$

```python
T = numpy.array([[0,0,6], [0.5,0,0], [0,1/3,0]])
```

```python
p = numpy.array([25, 10, 5])
N = 24
pn = numpy.zeros((N, len(p)))
pn[0,:] = p.copy()
for i in range(1,N):
    pn[i,:] = T.dot(pn[i-1,:])

pyplot.figure(figsize=(4,1))
pyplot.plot(pn[:,0],)
pyplot.plot(pn[:,1],)
pyplot.plot(pn[:,2],)
pyplot.xlabel('Years')
pyplot.title('Population in each age group');
```

In this case, the initial population distribution repeats every few years! If we look at the eigenvectors for the matrix, in this case ... gadzooks! They are complex numbers.
In Python, complex numbers are represented as the sum of a real part and an imaginary part, using the symbol `j` for the imaginary unit corresponding to $\sqrt{-1}$.

```python
numpy.linalg.eig(T)[0]
```

    array([-0.5+0.8660254j, -0.5-0.8660254j,  1. +0.j       ])

### Markov chains

Population models are one example of _discrete dynamical systems_, where eigenvalues and eigenvectors play a central role. Another example are _Markov chains_, of broad application in economics, genetics, game theory, and more.

This example is from Ref. [1]. A movie rental company has three offices in a city. You can rent movies from one office, and return them the next day in any office. Suppose the 3D vector $\mathbf{x}$ represents the percentage of the stock of "The Matrix" in each office.

From historical data, one could build a matrix $P$ with the probabilities that a customer rented "The Matrix" from office $j$ and returns it at office $i$ the next day. Say,

$$ P = \begin{bmatrix} 0.3 & 0.4 & 0.5 \\
                       0.3 & 0.4 & 0.3 \\
                       0.4 & 0.2 & 0.2 \end{bmatrix}
$$

A customer must return the movie in one of the three offices, so the probabilities for the three offices have to add up to $1$.
If $\mathbf{x}_k$ is the movie-stock distribution on a given day, then $P\,\mathbf{x}_k$ is the per-office distribution on the next day:

$$ \mathbf{x}_{k+1} = P\,\mathbf{x}_k  $$

##### Definition:

> A **probability vector** has non-negative components that add to $1$. A **stochastic matrix** is a square matrix whose columns are probability vectors.

> A **Markov chain** is a sequence of probability vectors $ \mathbf{x}_{k+1} = P\,\mathbf{x}_k  $, where $P$ is a stochastic matrix.

Let's play with with the stochastic matrix $P$. We can visually see that each column adds up to $1$, so no need to compute that. But let's get the eigenvalues and eigenvectors using SymPy.

```python
P = sympy.Matrix([[0.3,0.4,0.5], [0.3,0.4,0.3],[0.4,0.2,0.2]])
```

```python
P.eigenvals()
```

$$\left \{ - \frac{1}{5} : 1, \quad \frac{1}{10} : 1, \quad 1 : 1\right \}$$

```python
P.eigenvects()
```

$$\left [ \left ( -0.2, \quad 1, \quad \left [ \left[\begin{matrix}-1.0\\0\\1.0\end{matrix}\right]\right ]\right ), \quad \left ( 0.1, \quad 1, \quad \left [ \left[\begin{matrix}0.5\\-1.5\\1.0\end{matrix}\right]\right ]\right ), \quad \left ( 1.0, \quad 1, \quad \left [ \left[\begin{matrix}1.4\\1.2\\1.0\end{matrix}\right]\right ]\right )\right ]$$

We see that the largest eigenvalue is $1$. This is true for _all_ stochastic matrices, in fact: they have $1$ as eigenvalue and all other eigenvalues are smaller in absolute value.

##### Key idea:

> Stochastic matrices have $1$ as the dominant eigenvalue: if $s_k$ are the eigenvalues, $|s_k|\leq 1$.

Think about it: multiplying a matrix by the vector of ones $(1, 1, 1)$ adds up the rows (linear combination of the columns, each multiplied by $1$). For a stochastic matrix $P$, the rows of the transpose $P^T$ add up to $1$, so: $P^T \mathbf{1} = \mathbf{1}$, which means $1$ is an eigenvalue of $P^T$ and $P$ (a matrix and its transpose have the same eigenvalues).

```python
one_vector = sympy.Matrix([1,1,1])

P.transpose()@one_vector
```

$$\left[\begin{matrix}1.0\\1.0\\1.0\end{matrix}\right]$$

```python
P.eigenvals() == P.transpose().eigenvals()
```

    True

Let's see what happens to the vector $\mathbf{x}$, representing the percentages of the movie stock in each office, after several days.

```python
P2 = numpy.array(P)
x = numpy.array([0.1, 0.4, 0.5])
N = 10
xn = numpy.zeros((N, len(p)))
xn[0,:] = x.copy()
for i in range(1,N):
    xn[i,:] = P2.dot(xn[i-1,:])

pyplot.figure(figsize=(4,1))
pyplot.plot(xn[:,0],)
pyplot.plot(xn[:,1],)
pyplot.plot(xn[:,2],)
pyplot.xlabel('days')
pyplot.title('Percentage of stock at each office');
```

You can try it with different initial distributions of the movie stock, and you will see that the long-term distribution is always the same: the long-term behavior of a Markov chain is a **steady state**. This steady state is aligned with the eigenvector corresponding to eigenvalue $1$. Let's check:

```python
steady = sympy.Matrix(xn[-1,:])
steady.normalized()
```

$$\left[\begin{matrix}0.667424016931347\\0.572077528097156\\0.476731038919718\end{matrix}\right]$$

```python
evec = sympy.Matrix(P.eigenvects()[2][2])
evec.normalized()
```

$$\left[\begin{matrix}0.667423812471915\\0.572077553547355\\0.476731294622796\end{matrix}\right]$$

The normalized steady-state vector and eigenvector corresponding to the $1$ eigenvalue are the same, up to a small difference that will diminish if we add more steps to the chain.

How can we explain this? First, remember that $ \mathbf{x}_{k+1} = P\,\mathbf{x}_k  $. So, $ \mathbf{x}_2 = P\, \mathbf{x}_1 = P^2 \mathbf{x}_0 $. In general,

$$ \mathbf{x}_{k} = P^{k}\,\mathbf{x}_0  $$

Second, the eigenvectors form a basis, so we can write any vector as a linear combination of them. Let's call them $\mathbf{e}_1$, $\mathbf{e}_2$, $\mathbf{e}_3$:
$$ \mathbf{x}_0 = c_1 \mathbf{e}_1 + c_2 \mathbf{e}_2 + c_3 \mathbf{e}_3 $$

Multiply by the matrix $P$ on both sides, and on the right side use the definition of eigenvectors that $P \mathbf{e}_i = s_i \mathbf{e}_i$ (where $s_i$ are the eigenvalues):

$$ P\, \mathbf{x}_0 = c_1 s_1\mathbf{e}_1 + c_2 s_2\mathbf{e}_2 + c_3 s_3\mathbf{e}_3 $$

If you multiply $k$ times by $P$, you get:

$$ P^{\,k}\, \mathbf{x}_0 = c_1 s_1^k\mathbf{e}_1 + c_2 s_2^k\mathbf{e}_2 + c_3 s_3^3\mathbf{e}_3 $$

The only term in the linear combination that survives long-term is the one aligned with the eigenvector corresponding to eigenvalue $1$. The powers of other eigenvalues for which $|s_i|<1$ all tend to zero as $k$ increases.

##### Key idea:

> A **steady state** of a Markov chain is an eigenvector of the stochastic matrix, corresponding to eigenvalue $1$.

##### Perron-Frobenius Theorem

> A _positive stochastic matrix_ (all positive entries) has a unique steady state vector to which all initial states converge.

Here's another way to look at it (see $\S$6.6 of Ref. [1] for more discussion). The eigendecomposition of $P$ is formed with the two matrices returned by `diagonalize()`:

```python
P.diagonalize()
```

$$\left ( \left[\begin{matrix}-1.0 & 1.0 & 7.0\\0 & -3.0 & 6.0\\1.0 & 2.0 & 5.0\end{matrix}\right], \quad \left[\begin{matrix}-0.2 & 0 & 0\\0 & 0.1 & 0\\0 & 0 & 1.0\end{matrix}\right]\right )$$

The matrix $P$ is _similar_ (has the same effect via a change of basis) to a scaling matrix which, when repeatedly applied, shrinks al components _not_ parallel to the eigenvector corresponding to eigenvalue $1$.

### Google's PageRank algorithm

Web search today uses a variety of technologies, but the original algorithm that rocketed Google to the top of search engines is the _PageRank_ algorithm. Google's own description was:

> PageRank works by counting the number and quality of links to a page to determine a rough estimate of how important the website is. The underlying assumption is that more important websites are likely to receive more links from other websites.

(Cited in the Wikipedia entry for [PageRank](https://en.wikipedia.org/wiki/PageRank), from an archived Google web page.)

A web page's rank of importance is computed from the number of links to it, and the importance of the pages that link to it. The rule is that a page linking to $n$ other pages passes to them $1/n$ of its importance. A page's rank is the sum of all the importance it gets from other pages linking to it.

For a network of $n$ web pages, you form a matrix whose $i,j$ entry is the importance page $j$ passes to $i$.
Here's an example from Ref. [1]. We have a network with four web pages, connected by links as shown in the figure below.

#### Example network of four web pages, from Ref. [1], $\S$6.6.

* Page A has 3 links: it passes $1/3$ of its rank to $B, C, D$
* Page B has 2 links: it passes $1/2$ of its rank to $C, D$
* Page C has 0 links
* Page D has 2 links: it passes $1/2$ of its rank o $A,C$

The importance matrix is:
$$A = \begin{bmatrix} 0   & 0   & 0 & 1/2 \\
                       1/3 & 0   & 0 & 0  \\
                       1/3 & 1/2 & 0 & 1/2 \\
                       1/3 & 1/2 & 0 & 0 \end{bmatrix}
$$

This would be a stochastic matrix, except for the column of zeros, due to a web page with no links. Fix this problem by replacing the zeros in that column with $1/n$. This _modified_ importance matrix, $A^{\prime}$ is always stochastic.  Now choose a value $p \in (0,1)$, called the _damping factor_, to build the **Google matrix** as follows:

$$
  G = (1-p)\, A^{\prime} + p\, B
$$

where $B$ is an $n\times n$ matrix with $1/n$ in every entry.

##### Key idea:

> The Google matrix is a positive stochastic matrix. The PageRank vector is the steady state vector of the Markov chain with this matrix.

Let's play with a network of four pages like the one illustrated above, but fill the column corresponding to $C$ with $1/4$. Choose $p=0.15$ for the damping factor.

```python
Amod = numpy.array([[0,0,1/4, 1/2], [1/3,0,1/4,0], [1/3,1/2,1/4,1/2],[1/3,1/2,1/4,0]])
Amod
```

    array([[0.        , 0.        , 0.25      , 0.5       ],
           [0.33333333, 0.        , 0.25      , 0.        ],
           [0.33333333, 0.5       , 0.25      , 0.5       ],
           [0.33333333, 0.5       , 0.25      , 0.        ]])

Let's write a Python function with the PageRank algorithm, taking as inputs the modified importance matrix, the damping factor, and a tolerance to exit the Markov chain. The function initializes a random starting vector (normalized), computes the Google matrix, then applies it repeatedly until two successive state vectors are "close enough" to each other (i.e., by less than the tolerance).

```python
def pagerank(Amod, p=0.15, eps=1e-3):
    '''Implements the PageRank algorithm
    Arguments:
    Amod numpy array, modified importance matrix
    p    float, damping factor
    eps  float, tolerance to exit the iterations

    Returns:
    v (normalized) numpy array, PageRank vector
    '''
    n = Amod.shape[1]
    v = numpy.random.rand(n,1)
    v = v/numpy.linalg.norm(v)
    G = (1-p)*Amod + p*1/n*numpy.ones((n,n))
    vold = numpy.zeros((n,1))

    while numpy.linalg.norm(v-vold, 2)>eps:
        vold = v.copy()
        v = G.dot(v)

    return v/numpy.sum(v)
```

```python
v = pagerank(Amod)
v
```

    array([[0.2193081 ],
           [0.17521869],
           [0.35582464],
           [0.24964857]])

The result above is the PageRank vector for this small network of four web pages. The largest vector value is the third component, corresponding to page $C$, the highest-ranked page. Page $B$ is the lowest ranked (second component).
The interpretation of this result is as a probability distribution for the likelihood that a person surfing the web will randomly click through to arrive at one of the pages in the network.

##### Exercise

Build the Google matrix with $p=0.15$ for the network illustrated below, then find the PageRank vector. Ponder the result and identify the highest ranked and lowest ranked pages.

#### Example network with eight web pages, from Ref. [3], $\S$4.5.

## References

1. Dan Margalit and Joseph Rabinoff (2018), [_Interactive Linear Algebra_](https://textbooks.math.gatech.edu/ila/index.html), an open book under the GNU Free Documentation License, Version 1.2.

2. Bernardelli, H. (1941), Population waves, Journal of the Burma Research Society 31, Part 1. Also in _Mathematical Demography, Selected Papers_ (1977), pp. 215-219. Springer, Berlin, Heidelberg. https://doi.org/10.1007/978-3-642-81046-6_24

3. David Austin (2019), [_Understanding Linear Algebra_](http://merganser.math.gvsu.edu/david/linear.algebra/ula/ula/ula.html), an open book under CC-By license.

```python
# Execute this cell to load the notebook's style sheet, then ignore it
from IPython.core.display import HTML
css_file = '../style/custom.css'
HTML(open(css_file, "r").read())
```

<link href="https://fonts.googleapis.com/css?family=Merriweather:300,300i,400,400i,700,700i,900,900i" rel='stylesheet' >
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,700,700i" rel='stylesheet' >
<link href='http://fonts.googleapis.com/css?family=Source+Code+Pro:300,400' rel='stylesheet' >
<style>

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

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

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

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

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

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

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

div.text_cell_render{
    font-family: 'Source Sans Pro', sans-serif;
    line-height: 140%;
    font-size: 110%;
    width:680px;
    margin-left:auto;
    margin-right:auto;
}

/* Formatting for header cells */
.text_cell_render h1 {
    font-family: 'Merriweather', serif;
    font-style:regular;
    font-weight: bold;
    font-size: 250%;
    line-height: 100%;
    color: #004065;
    margin-bottom: 1em;
    margin-top: 0.5em;
    display: block;
}
.text_cell_render h2 {
    font-family: 'Merriweather', serif;
    font-weight: bold;
    font-size: 180%;
    line-height: 100%;
    color: #0096d6;
    margin-bottom: 0.5em;
    margin-top: 0.5em;
    display: block;
}

.text_cell_render h3 {
    font-family: 'Merriweather', serif;
	font-size: 150%;
    margin-top:12px;
    margin-bottom: 3px;
    font-style: regular;
    color: #008367;
}

.text_cell_render h4 {    /*Use this for captions*/
    font-family: 'Merriweather', serif;
    font-weight: 300;
    font-size: 100%;
    line-height: 120%;
    text-align: left;
    width:500px;
    margin-top: 1em;
    margin-bottom: 2em;
    margin-left: 80pt;
    font-style: regular;
}

.text_cell_render h5 {  /*Use this for small titles*/
    font-family: 'Source Sans Pro', sans-serif;
    font-weight: regular;
    font-size: 130%;
    color: #e31937;
    font-style: italic;
    margin-bottom: .5em;
    margin-top: 1em;
    display: block;
}

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

    .CodeMirror{
            font-family: "Source Code Pro";
			font-size: 90%;
    }
/*    .prompt{
        display: None;
    }*/

    .warning{
        color: rgb( 240, 20, 20 )
        }
</style>


# PyStein Example

This notebook demonstrates a simple use of the `pystein` package. See the [documentation](https://pystein.readthedocs.io/en/latest/) for more.

## Imports

```python
import sympy
from sympy.diffgeom import Manifold, Patch

from pystein import coords, metric, curvature
from pystein.utilities import tensor_pow as tpow
```

## Create a Metric: $S^2$

First create the manifold and patch object, though these won't be used much yet `sympy.diffgeom` intends to add features for them later.

```python
M = Manifold('M', dim=2)
P = Patch('origin', M)
```

Define the coordinates for the sphere ($\theta, \phi$), as well as a constant $a$

```python
theta, phi, a = sympy.symbols('theta phi a', nonnegative=True)
cs = coords.CoordSystem('spherical', P, [theta, phi])
cs
```

Next, we extract the one-forms from the coordinate system. We will construct a two-form in terms of these coordinate one-forms.

```python
dtheta, dphi = cs.base_oneforms()
```

Construct the twoform

```python
ds2 = a**2 * (tpow(dtheta, 2) + sympy.sin(theta)**2 * tpow(dphi, 2))
```

Construct the metric:

```python
# Construct a metric from the two form
g = metric.Metric(twoform=ds2)
g
```

## Compute Curvature Components

Components can be computed individually:

```python
curvature.christoffel_symbol_component(0, 1, 0, g)
```

Or the common components can be computed all at once:

```python
christoffels, riemanns, riccis = curvature.compute_components(g, non_trivial=True)
```

```python
christoffels[0]
```

The components can be displayed concisely as a single $\LaTeX$ equation:

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