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

# 105B Practice problem

## Q1: 对函数 $f(x)=\frac{1}{1+9x^2}$ 在 x=-1, -0.5, 0, 0.5, 1 处 使用拉格朗日插值多项式近似

```python
linx=np.linspace(-1,1,5)
linx
```

    array([-1. , -0.5,  0. ,  0.5,  1. ])

```python
f=lambda x: 1/(1+9*x**2)

liny=[]
for i in range(len(linx)):
    liny.append(f(linx[i]))
liny
```

    [0.1, 0.3076923076923077, 1.0, 0.3076923076923077, 0.1]

### $P(x)=L_{n,0} (x) y_0+L_{n,1} (x) y_1+⋯+L_{n,n} (x) y_n=\sum_{k=0}^n L_{n,k} (x) y_k $
其中 $$L_{n,k}=\prod_{i=0, i\neq k}^n \frac{x-x_i}{x_k-x_i}$$

```python
x=sym.Symbol('x')
def Lag_Poly(linx,liny):
    # input: 插值点
    # output: 拉格朗日函数 symbol form
    n=len(linx)
    x=sym.Symbol('x')
    bigsum=0
    for k in range(len(linx)):
        prod=1
        for i in range(len(linx)):
            if i!=k:
                prod=prod*(x-linx[i])/(linx[k]-linx[i])
        bigsum+=prod*liny[k]
    return bigsum
```

```python
P=Lag_Poly(linx,liny)
```

```python
P
```

$\displaystyle - 0.0333333333333333 x \left(- 2.0 x - 1.0\right) \left(x - 1.0\right) \left(x - 0.5\right) + 0.133333333333333 x \left(0.5 x + 0.5\right) \left(x - 0.5\right) \left(x + 0.5\right) - 1.23076923076923 x \left(0.666666666666667 x + 0.666666666666667\right) \left(x - 1.0\right) \left(x + 0.5\right) - 0.41025641025641 x \left(x - 1.0\right) \left(x - 0.5\right) \left(2.0 x + 2.0\right) + 4.0 \left(x - 1.0\right) \left(x - 0.5\right) \left(x + 0.5\right) \left(1.0 x + 1.0\right)$

```python
linx_for_plot=np.linspace(linx[0],linx[-1],100)
liny_fx=list(map(f,linx_for_plot))
liny_px=[]
for i in range(len(linx_for_plot)):
    liny_px.append(P.subs(x,linx_for_plot[i]))
```

```python
fig, ax = plt.subplots()
ax.plot(linx_for_plot,liny_fx,'r',linx_for_plot,liny_px,'b')
ax.plot(linx,liny,'ro')
ax.legend(['f(x)','P(x)','interpolating points'])

```

```python

```


# Functions
So far in this course we've explored equations that perform algebraic operations to produce one or more results. A *function* is a way of encapsulating an operation that takes an input and produces exactly one ouput.

For example, consider the following function definition:

\begin{equation}f(x) = x^{2} + 2\end{equation}

This defines a function named ***f*** that accepts one input (***x***) and returns a single value that is the result calculated by the expression *x2 + 2*.

Having defined the function, we can use it for any input value. For example:

\begin{equation}f(3) = 11\end{equation}

You've already seen a few examples of Python functions, which are defined using the **def** keyword. However, the strict definition of an algebraic function is that it must return a single value. Here's an example of defining and using a Python function that meets this criteria:

```python
# define a function to return x^2 + 2
def f(x):
    return x**2 + 2

# call the function
f(3)
```

    11

You can use functions in equations, just like any other term. For example, consider the following equation:

\begin{equation}y = f(x) - 1\end{equation}

To calculate a value for ***y***, we take the ***f*** of ***x*** and subtract 1. So assuming that ***f*** is defined as previously, given an ***x*** value of 4, this equation returns a ***y*** value of **17** (*f*(4) returns 42 + 2, so 16 + 2 = 18; and then the equation subtracts 1 to give us 17). Here it is in Python:

```python
x = 4
y = f(x) - 1
print(y)
```

    17

Of course, the value returned by a function depends on the input; and you can graph this with the iput (let's call it ***x***) on one axis and the output (***f(x)***) on the other.

```python
%matplotlib inline

import numpy as np
from matplotlib import pyplot as plt

# Create an array of x values from -100 to 100
x = np.array(range(-100, 101))

# Set up the graph
plt.xlabel('x')
plt.ylabel('f(x)')
plt.grid()

# Plot x against f(x)
plt.plot(x,f(x), color='purple')

plt.show()
```

As you can see (if you hadn't already figured it out), our function is a *quadratic function* - it returns a squared value that results in a parabolic graph when the output for multiple input values are plotted.

## Bounds of a Function
Some functions will work for any input and may return any output. For example, consider the function ***u*** defined here:

\begin{equation}u(x) = x + 1\end{equation}

This function simply adds 1 to whatever input is passed to it, so it will produce a defined output for any value of ***x*** that is a *real* number; in other words, any "regular" number - but not an *imaginary* number like √-1, or ∞ (infinity). You can specify the set of real numbers using the symbol ${\rm I\!R}$ (note the double stroke). The values that can be used for ***x*** can be expressed as a *set*, which we indicate by enclosing all of the members of the set in "{...}" braces; so to indicate the set of all possible values for x such that x is a member of the set of all real numbers, we can use the following expression:

\begin{equation}\{x \in \rm I\!R\}\end{equation}

### Domain of a Function
We call the set of numbers for which a function can return value it's *domain*, and in this case, the domain of ***u*** is the set of all real numbers; which is actually the default assumption for most functions.

Now consider the following function ***g***:

\begin{equation}g(x) = (\frac{12}{2x})^{2}\end{equation}

If we use this function with an ***x*** value of **2**, we would get the output **9**; because (12 ÷ (2•2))2 is 9. Similarly, if we use the value **-3** for ***x***, the output will be **4**. However, what happens when we apply this function to an ***x*** value of **0**? Anything divided by 0 is undefined, so the function ***g*** doesn't work for an ***x*** value of 0.

So we need a way to denote the domain of the function ***g*** by indicating the input values for which a defined output can be returned. Specifically, we need to restrict ***x*** to a specific list of values - specifically any real number that is not 0. To indicate this, we can use the following notation:

\begin{equation}\{x \in \rm I\!R\;\;|\;\; x \ne 0 \}\end{equation}

This is interpreted as *Any value for x where x is in the set of real numbers such that x is not equal to 0*, and we can incorporate this into the function's definition like this:

\begin{equation}g(x) = (\frac{12}{2x})^{2}, \{x \in \rm I\!R\;\;|\;\; x \ne 0 \}\end{equation}

Or more simply:

\begin{equation}g(x) = (\frac{12}{2x})^{2},\;\; x \ne 0\end{equation}

When you plot the output of a function, you can indicate the gaps caused by input values that are not in the function's domain by plotting an empty circle to show that the function is not defined at this point:

```python
%matplotlib inline

# Define function g
def g(x):
    if x != 0:
        return (12/2*x)**2

# Plot output from function g
import numpy as np
from matplotlib import pyplot as plt

# Create an array of x values from -100 to 100
x = range(-100, 101)

# Get the corresponding y values from the function
y = [g(a) for a in x]

# Set up the graph
plt.xlabel('x')
plt.ylabel('g(x)')
plt.grid()

# Plot x against g(x)
plt.plot(x,y, color='purple')

# plot an empty circle to show the undefined point
plt.plot(0,g(0.0000001), color='purple', marker='o', markerfacecolor='w', markersize=8)

plt.show()
```

Note that the function works for every value other than 0; so the function is defined for x = 0.000000001, and for x = -0.000000001; it only fails to return a defined value for exactly 0.

OK, let's take another example. Consider this function:

\begin{equation}h(x) = 2\sqrt{x}\end{equation}

Applying this function to a non-negative ***x*** value returns a meaningful output; but for any value where ***x*** is negative, the output is undefined.

We can indicate the domain of this function in its definition like this:

\begin{equation}h(x) = 2\sqrt{x}, \{x \in \rm I\!R\;\;|\;\; x \ge 0 \}\end{equation}

This is interpreted as *Any value for x where x is in the set of real numbers such that x is greater than or equal to 0*.

Or, you might see this in a simpler format:

\begin{equation}h(x) = 2\sqrt{x},\;\; x \ge 0\end{equation}

Note that the symbol ≥ is used to indicate that the value must be *greater than **or equal to*** 0; and this means that **0** is included in the set of valid values. To indicate that the value must be *greater than 0, **not including 0***, use the > symbol. You can also use the equivalent symbols for *less than or equal to* (≤) and *less than* (<).

When plotting a function line that marks the end of a continuous range, the end of the line is shown as a circle, which is filled if the function includes the value at that point, and unfilled if it does not.

Here's the Python to plot function ***h***:

```python
%matplotlib inline

def h(x):
    if x >= 0:
        import numpy as np
        return 2 * np.sqrt(x)

# Plot output from function h
import numpy as np
from matplotlib import pyplot as plt

# Create an array of x values from -100 to 100
x = range(-100, 101)

# Get the corresponding y values from the function
y = [h(a) for a in x]

# Set up the graph
plt.xlabel('x')
plt.ylabel('h(x)')
plt.grid()

# Plot x against h(x)
plt.plot(x,y, color='purple')

# plot a filled circle at the end to indicate a closed interval
plt.plot(0, h(0), color='purple', marker='o', markerfacecolor='purple', markersize=8)

plt.show()
```

Sometimes, a function may be defined for a specific *interval*; for example, for all values between 0 and 5:

\begin{equation}j(x) = x + 2,\;\; x \ge 0 \text{ and } x \le 5\end{equation}

In this case, the function is defined for ***x*** values between 0 and 5 *inclusive*; in other words, **0** and **5** are included in the set of defined values. This is known as a *closed* interval and can be indicated like this:

\begin{equation}\{x \in \rm I\!R\;\;|\;\; 0 \le x \le 5 \}\end{equation}

It could also be indicated like this:

\begin{equation}\{x \in \rm I\!R\;\;|\;\; [0,5] \}\end{equation}

If the condition in the function was **x > 0 and x < 5**, then the interval would be described as *open* and 0 and 5 would *not* be included in the set of defined values. This would be indicated using one of the following expressions:

\begin{equation}\{x \in \rm I\!R\;\;|\;\; 0 \lt x \lt 5 \}\end{equation}
\begin{equation}\{x \in \rm I\!R\;\;|\;\; (0,5) \}\end{equation}

Here's function ***j*** in Python:

```python
%matplotlib inline

def j(x):
    if x >= 0 and x <= 5:
        return x + 2

# Plot output from function j
import numpy as np
from matplotlib import pyplot as plt

# Create an array of x values from -100 to 100
x = range(-100, 101)
y = [j(a) for a in x]

# Set up the graph
plt.xlabel('x')
plt.ylabel('j(x)')
plt.grid()

# Plot x against k(x)
plt.plot(x, y, color='purple')

# plot a filled circle at the ends to indicate an open interval
plt.plot(0, j(0), color='purple', marker='o', markerfacecolor='purple', markersize=8)
plt.plot(5, j(5), color='purple', marker='o', markerfacecolor='purple', markersize=8)

plt.show()
```

Now, suppose we have a function like this:

\begin{equation}
k(x) = \begin{cases}
  0, & \text{if } x = 0, \\
  1, & \text{if } x = 100
\end{cases}
\end{equation}

In this case, the function has highly restricted domain; it only returns a defined output for 0 and 100. No output for any other ***x*** value is defined. In this case, the set of the domain is:

\begin{equation}\{0,100\}\end{equation}

Note that this does not include all real numbers, it only includes 0 and 100.

When we use Python to plot this function, note that it only makes sense to plot a scatter plot showing the individual values returned, there is no line in between because the function is not continuous between the values within the domain.

```python
%matplotlib inline

def k(x):
    if x == 0:
        return 0
    elif x == 100:
        return 1

# Plot output from function k
from matplotlib import pyplot as plt

# Create an array of x values from -100 to 100
x = range(-100, 101)
# Get the k(x) values for every value in x
y = [k(a) for a in x]

# Set up the graph
plt.xlabel('x')
plt.ylabel('k(x)')
plt.grid()

# Plot x against k(x)
plt.scatter(x, y, color='purple')

plt.show()
```

### Range of a Function
Just as the domain of a function defines the set of values for which the function is defined, the *range* of a function defines the set of possible outputs from the function.

For example, consider the following function:

\begin{equation}p(x) = x^{2} + 1\end{equation}

The domain of this function is all real numbers. However, this is a quadratic function, so the output values will form a parabola; and since the function has no negative coefficient or constant, it will be an upward opening parabola with a vertex that has a y value of 1.

So what does that tell us? Well, the minimum value that will be returned by this function is 1, so it's range is:

\begin{equation}\{p(x) \in \rm I\!R\;\;|\;\; p(x) \ge 1 \}\end{equation}

Let's create and plot the function for a range of ***x*** values in Python:

```python
%matplotlib inline

# define a function to return x^2 + 1
def p(x):
    return x**2 + 1

# Plot the function
import numpy as np
from matplotlib import pyplot as plt

# Create an array of x values from -100 to 100
x = np.array(range(-100, 101))

# Set up the graph
plt.xlabel('x')
plt.ylabel('p(x)')
plt.grid()

# Plot x against f(x)
plt.plot(x,p(x), color='purple')

plt.show()
```

Note that the ***p(x)*** values in the plot drop exponentially for ***x*** values that are negative, and then rise exponentially for positive ***x*** values; but the minimum value returned by the function (for an *x* value of 0) is **1**.


```python
import sympy as sym
import numpy as np

import testzd as zd
```

This notebook verifies the various algebraic steps required to prove the general form of an extortionate strategy $p\in\mathbb{R}^{4\times 1}$:

```python
tilde_p = sym.symbols("tilde_p1:5")
R, S, T, P = sym.symbols("R, S, T, P")
alpha, beta = sym.symbols("alpha, beta")
tilde_p
```

    (tilde_p1, tilde_p2, tilde_p3, tilde_p4)

```python
eqn_1 = sym.Eq(tilde_p[0], alpha * (R - P) + beta * (R - P))
eqn_2 = sym.Eq(tilde_p[1], alpha * (S - P) + beta * (T - P))
eqn_3 = sym.Eq(tilde_p[2], alpha * (T - P) + beta * (S - P))
eqn_4 = sym.Eq(tilde_p[3], 0)
```

```python
solved_alpha = sym.solveset(eqn_2, alpha).args[0]
solved_alpha
```

    (-beta*(P - T) - tilde_p2)/(P - S)

Formula for $\beta$:

```python
solved_beta = sym.solveset(eqn_3.subs({alpha: solved_alpha}), beta).args[0]
sym.simplify(solved_beta)
```

    (P*tilde_p2 - P*tilde_p3 + S*tilde_p3 - T*tilde_p2)/((S - T)*(-2*P + S + T))

Formula for $\alpha$:

```python
solved_alpha = solved_alpha.subs({beta: solved_beta})
sym.simplify(solved_alpha)
```

    (P*tilde_p2 - P*tilde_p3 - S*tilde_p2 + T*tilde_p3)/(2*P*S - 2*P*T - S**2 + T**2)

Formula for $p_1$:

```python
sym.simplify(eqn_1.subs({alpha: solved_alpha, beta: solved_beta}))
```

    Eq(tilde_p1, (P*tilde_p2 + P*tilde_p3 - R*tilde_p2 - R*tilde_p3)/(2*P - S - T))

Formula for $\chi$:

```python
sym.simplify(- solved_beta / solved_alpha)
```

    (P*tilde_p2 - P*tilde_p3 + S*tilde_p3 - T*tilde_p2)/(P*tilde_p2 - P*tilde_p3 - S*tilde_p2 + T*tilde_p3)


# Exercise 9) Function Approximators in Prediction

Up to now, we have used tabular methods to store data. These methods are rather simple concerning implementation and use, but they lack efficiency in several fields:

- Tabular methods need a lot of storage capacity. Saving one number per possible state (or state-action combination) is very expensive for systems with a large discrete problem space. It even gets impossible when looking at continuous problem spaces.
- Tabular methods are unable to generalize. Every update to the table only contains information about one specific state, which means that we only learn about states we have seen and not about the states that are "near" them. We can decide if we want to solve this issue by extending the training time or by lowering our expectations of the outcome.

or alternatively, we make use of function approximators!

For our investigations, we will have a look at the MountainCar environment from OpenAI's `gym`.

This system has a continuous two-dimensional state space and a discrete one-dimensional action space.

The MountainCar can be compared to the pendulum concerning that a successful policy must be able to perform a swing-up movement. The car has limited ability to accelerate uphill, it has to build up velocity by accelerating downhill.
In contrast to the pendulum scenario, the MountainCar terminates upon reaching the goal on the mountaintop to the right. Every timestep will be rewarded with a reward of $r_{k+1}=-1$, such that it is most beneficial to end an episode as fast as possible.

For this exercise we want to concentrate on the evaluation of an existing policy.

(Source of GIF: https://marcinbogdanski.github.io/rl-sketchpad/Deep_Q_Network/1010_DQN_ClassicControl.html)

Please make sure to have `TensorFlow` installed:

`pip install tensorflow`

```python
import numpy as np
import gym
import random
import time
from tqdm.notebook import tqdm
import matplotlib.pyplot as plt
plt.style.use('seaborn-talk')
from mpl_toolkits.mplot3d import Axes3D

import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
from tensorflow.keras.optimizers import SGD
```

Test the environment:

```python
env = gym.make('MountainCar-v0')
state = env.reset()

while True:
    env.render()
    state, reward, done, _ = env.step(env.action_space.sample())

    if done:
        break

env.close()
```

## 1) Linear Function Approximation

The next cell contains a simple policy for the swing-up of the MountainCar. We want to predict the value function with the use of a linear function approximator of the form:

$\hat{v}(\mathbf{x_k})=\mathbf{w}^\text{T} \tilde{\mathbf{x}}_k$.

Herein, the weight vector is denoted by $\mathbf{w}$. The feature vector $\tilde{\mathbf{x}}_k$ is derived from the state vector $\mathbf{x}_k$:

$\tilde{\mathbf{x}}_k = f (\mathbf{x}_k)$

The state vector $\mathbf{x}_k$ consists of the (vertical) position and the velocity.

Write a Semi-Gradient TD(0) prediction algorithm that learns the weights of this linear value function approximator.
Make use of a `feature` function, that accepts the state vector as an input and returns a feature vector that is derived from the state. The feature vector should be equal to zero ($\tilde{\mathbf{x}}_T = \mathbf{0}$) if the finish line has been passed (this happens if the position is greater than $0.5$). Can you find a feature definition that enables a good value estimation?

Hint:
As it seems, the chances of successfully reaching the finish line rise when the car's energy increases. You may want to look at the [MountainCar sourcecode](https://github.com/openai/gym/blob/master/gym/envs/classic_control/mountain_car.py) to acquire expert knowledge for your feature definition.

```python
def policy(state):
    # 0: left, 1: idle, 2: right

    pos = state[0]
    vel = state[1]

    # fixed policy, do not change
    if vel <= 0:
        action = 0
    elif vel > 0:
        action = 2

    return action

def feature(state):
    pos = state[0]
    vel = state[1]

    # YOUR CODE HERE
    raise NotImplementedError()

    return feature_vec

```

```python
alpha = 0.1
gamma = 0.9
nb_episodes = 500

env = gym.make('MountainCar-v0')

state = env.reset()
feat_state = feature(state)
feat_dims = len(feat_state)

w = np.zeros([feat_dims])
visited_states = []

for j in tqdm(range(nb_episodes)):

    # YOUR CODE HERE
    raise NotImplementedError()
```

Evaluate the result parameter vector by investigating the performance of the value function approximator on the whole state space, preferrably in a plot. Which parts of the estimation seem accurate, which do not?

```python
# YOUR CODE HERE
raise NotImplementedError()
```

```python
# YOUR CODE HERE
raise NotImplementedError()
```

```python
# YOUR CODE HERE
raise NotImplementedError()
```

## 2) Recursive Least Squares TD

In the previous task we computed the parameters of the value function iteratively, by using old parameters and new observations to calculate new parameters. This works, but we can also be more data efficient. Recursive Least Squares Temporal Difference Learning (RLS-TD) allows to determine new parameters on the basis of new AND old observations, such that the parameters we receive are optimally fitted while taking past experiences into account. This method does not use a step size but only a forgetting factor $\lambda \in [0,1]$ which defines the impact of past experiences.

Write an RLS-TD algorithm to solve the prediction problem. Check the stability of your code for the forgetting factors $\lambda \in \{0.9, 0.99, 1\}$. As this algorithm contains a lot of matrix multiplication, pay attention to vectors being represented within the correct dimensions. One could e.g. use the `np.expand_dims(row_vector, axis=-1)` command to turn a row vector into a column vector.

The feature definition from task (1) can be reused here as long as $\tilde{\mathbf{x}}_T = \mathbf{0}$ holds.

```python
def feature(state):
    pos = state[0]
    vel = state[1]

    if pos > 0.5:
        win = 1
    else:
        win = 0

    # YOUR CODE HERE
    raise NotImplementedError()

    return feature_vec
```

```python
gamma = 0.9
_lambda = 1 # we call it like that because lambda is a defined command in python
nb_episodes = 500

env = gym.make('MountainCar-v0')
state = env.reset()
feat_state = feature(state)
feat_dims = len(feat_state)

P = np.eye(feat_dims)
w = np.zeros((feat_dims, 1))  # column vector

for j in tqdm(range(nb_episodes)):

    # YOUR CODE HERE
    raise NotImplementedError()
```

To simplify the comparison with your results from task (1), visualize the results from this task as well.

```python
# YOUR CODE HERE
raise NotImplementedError()
```

```python
# YOUR CODE HERE
raise NotImplementedError()
```

## 3) Nonlinear Function Approximation with Artificial Neural Networks

Maybe we can achieve a more precise value estimation if we use a nonlinear function approximator. The dynamics of the environment are nonlinear, so it would be reasonable to expect a better result. We will use an artificial neural network as our function approximator without feature engineering.

Neural networks tend to learn more reliably if the input variables are normalized. In this case, we will use minmax normalization. Write the function `normalize` that normalizes the MounatinCar state:

\begin{align}
\text{state}&\in
\begin{bmatrix}
[-1.2, 0.6] \\
[-0.07, 0.07]
\end{bmatrix}
\\
\text{normalize(state)}&\in
\begin{bmatrix}
[-1, 1] \\
[-1, 1]
\end{bmatrix}
\end{align}

`Tensorflow` is somewhat complicated to understand if you are new to it and efficient usage of it in reinforcement learning looks different from the usage in "traditional" supervised learning. That is why we prepared the code for the network training this time. You only need to write a proper `normalize` function here, but of course feel free to explore the learning algorithm (e.g. experiment with different ANN topologies) as a preparation for the upcoming exercises. A more detailed explanation about the usage of `Tensorflow` in this task will be presented in the corresponding tutorial video.

```python
def normalize(state):

    # YOUR CODE HERE
    raise NotImplementedError()

    return np.array([norm_pos, norm_vel])
```

```python
alpha = 0.0001
gamma = 0.9
nb_episodes = 1000

env = gym.make('MountainCar-v0')

# define ANN topology
model = Sequential()
model.add(Dense(16, activation='relu', input_dim=2))
model.add(Dense(16, activation='relu'))

model.add(Dense(1, activation='linear'))

opt = SGD(learning_rate=alpha)
mse = tf.keras.losses.MeanSquaredError()

errors = []

for j in tqdm(range(nb_episodes)):
    state = env.reset()
    norm_state = normalize(state)

    while True:
        #env.render()

        action = policy(state)
        next_state, reward, done, _ = env.step(action)

        # calculate the target (the value we want to estimate)
        norm_next_state = normalize(next_state)
        next_value = model(np.array([norm_next_state]))
        if not done:
            target = reward + gamma * next_value
        else:
            target = np.array([[reward]])

        # calculate the loss (the prediciton error)
        with tf.GradientTape() as tape:
            prediction = model(np.array([norm_state]))
            loss = mse(target, prediction)

        # calculate and apply the gradients dMSE/dw
        gradients = tape.gradient(loss, model.trainable_variables)
        opt.apply_gradients(zip(gradients, model.trainable_variables))

        norm_state = norm_next_state
        state = next_state

        errors.append(loss.numpy())

        if done:
            break

    #env.close()
```

This is how the `relu` activation looks like:

```python
x = np.linspace(-1, 1, 1000)
y = np.maximum(0, x)
ax = plt.plot(x, y)
plt.grid(True)
plt.xlim([-1, 1])
plt.ylim([-0.1, 1])
plt.xlabel(r"$x$")
plt.ylabel(r"$\mathrm{relu}(x)$")
```

Try to  visualize the results from this task as well.

```python
# YOUR CODE HERE
raise NotImplementedError()
```

```python
# YOUR CODE HERE
raise NotImplementedError()
```


# Taylor series
A continuous function $f(x)$ (with $f^n(x)$ exist and $f^{n+1}(x)$ continuous) can be expanded in the form of a Taylor series:
\begin{eqnarray}
f(x) &=& \sum_{n=0}^\infty \frac{f^n(a)}{n!}(x-a)^n \\
&=& f(a) + f'(a)(x-a) + \frac{f''(a)}{2!}(x-a)^2 + \cdots + \frac{f^{(k)}(a)}{k!}(x-a)^k + h_k(x)(x-a)^k
\end{eqnarray}
$\lim_{x\to a}h_k(x)=0$. This is called the Peano form of the remainder.

Clearly, since many derivatives are involved, a Taylor series expansion is only possible when the function is so smooth that it can be differentiated again and again. An infinite-order polynomial can yield an error-free approximation.

## Trigonometric function
Trigonometric functions are continuous and $n$th-order differentiable, there taylor series can be represented as:
\begin{equation}
 cos(x)= \sum_{n=0}^\infty \frac{(-1)^n}{(2n)!}x^{2n}
\end{equation}
\begin{equation}
 sin(x)= \sum_{n=0}^\infty \frac{(-1)^n}{(2n+1)!}x^{2n+1}
\end{equation}

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

def Plot(n, X, ORG, APPR, FOMU):
    plt.figure(figsize=(10,5))
    plt.xlim(-4*np.pi,4*np.pi)
    plt.ylim(-5,5)
    ax = plt.gca()
    ax.grid(color='#b7b7b7', linestyle='-', linewidth=0.5, alpha=0.5)
    ax.text(-12,4,FOMU + ', n='+str(n),fontsize=12,color='#000000')
    plt.plot(X, ORG, '-', color='black', lw=2)
    plt.plot(X, APPR, '-', color='#d3d3d3', lw=2)
    plt.xticks([-4*m.pi, -2*m.pi, 0, 2*m.pi, 4*m.pi], [r'$-4\pi$', r'$-2\pi$', r'$0$',r'$2\pi$', r'$4\pi$'])

def Series(n):
    APPR_SIN = 0
    APPR_COS = 0
    # FOMULA
    FOMU_SIN = '$\sum_{n=0}^\infty {(-1)^n}x^{2n+1}/{(2n+1)!}$'
    FOMU_COS = '$\sum_{n=0}^\infty {(-1)^n}x^{2n}/{(2n)!}$'
    X = np.arange(-4*np.pi,4*np.pi, 8*np.pi/1000)
    SIN = np.sin(X)
    COS = np.cos(X)

    for i in range(n+1):
        APPR_SIN = APPR_SIN + (-1)**(i)*((X)**(2*i+1))/m.factorial(2*i+1)
        APPR_COS = APPR_COS + (-1)**(i)*((X)**(2*i))/m.factorial(2*i)
    # SIN
    Plot(n,X,SIN,APPR_SIN, FOMU_SIN)
    # COS
    Plot(n,X,COS,APPR_COS, FOMU_COS)

Series(10)
```

Since $e^x$ is its own derivative, the Taylor series expansion for $f(x) = e^x$ is one of the simplest imaginable infinite series:
\begin{equation}
e^{x} = \sum_{n=0}^\infty \frac{x^n}{n!} = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \cdots
\end{equation}
We can easily generalize this formula to complex number:
\begin{eqnarray}
 e^{ix} = \sum_{n=0}^\infty \frac{(ix)^n}{n!} &=& 1 + ix + \frac{(ix)^2}{2!} + \frac{(ix)^3}{3!} + \cdots \\
 &=& 1 + ix - \frac{x^2}{2!} - \frac{ix^3}{3!} +\cdots \\
 &=& \left( 1 - \frac{x^2}{2!} + \cdots \right) + i\left( x - \frac{x^3}{3!} + \cdots \right) \\
\end{eqnarray}
where:
\begin{equation}
real\left\{e^{ix}\right\} =  1 - \frac{x^2}{2!} +  \frac{x^4}{4!} - \cdots
\end{equation}
\begin{equation}
imag\left\{e^{ix}\right\} =  x - \frac{x^3}{3!} +  \frac{x^5}{5!} - \cdots
\end{equation}
Recall cos and sin series:
\begin{equation}
 cos(x)= \sum_{n=0}^\infty \frac{(-1)^n}{(2n)!}x^{2n}
\end{equation}
\begin{equation}
 sin(x)= \sum_{n=0}^\infty \frac{(-1)^n}{(2n+1)!}x^{2n+1}
\end{equation}
We got the most beautiful formula of all time (known as Euler's formula):
\begin{equation}
e^{ix} = cos(x) + isin(x)
\end{equation}
Which establishes the fundamental relationship between the trigonometric functions and the complex exponential function.

## Reference:

- [Mathematics of the discrete Fourier transform](https://ccrma.stanford.edu/~jos/st/)
- [Taylor series - Wikipedia](https://en.wikipedia.org/wiki/Taylor_series)
- [Euler's formula - Wikipedia](https://en.wikipedia.org/wiki/Euler%27s_formula)
- [Intuitive Understanding Of Euler’s Formula](https://betterexplained.com/articles/intuitive-understanding-of-eulers-formula/)
- [3Blue1Brown - YouTube](https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw)


# 求值

符号运算一种输出自然是带入符号求值了,我们推导了半天,很多时候是为了最终计算出数值解的.

求值有3种方式:

+ 求符号的值
+ 带入符号求算式的值
+ 将算式转化为python函数

## 符号求值

符号求值使用`evalf`方法,默认的精度是小数点后15位

```python
import sympy as sp
```

```python
sp.pi.evalf()
```

    3.14159265358979

也可以带上精度

```python
sp.pi.evalf(5)
```

    3.1416

```python
sp.sin(2).evalf(3)
```

    0.909

## 带入符号求值

符号运算一种输出自然是带入符号求值了,我们推导了半天,很多时候是为了最终计算出数值解的.

我们可以使用算式对象的`subs`方法为自变量符号代入具体数值以求解

```python
x,y,z = sp.symbols("x,y,z")
```

```python
f = x+2*y+z
```

```python
f.subs([(x,1),(y,2),(z,3)])
```

$\displaystyle 8$

## 将算式转换为python函数

另一种用处是使用函数`lambdify(params:Tuple[symbols],exp,package:str)=>Callable`把算式转换成python函数,末位参数表示一些用到的运算使用哪个包,支持的有:

+ `sympy`,自带的运算库
+ `math`标准库math
+ `numnpy`
+ `scipy`
+ `numexpr`
+ `mpmath`,sympy依赖的运算库
+ `tensorflow`

默认是:
+ 如果装了scipy就是`["scipy", "numpy"]`
+ 如果装了numpy就是`["numpy"]`
+ 如果都没装就是`["math", "mpmath", "sympy"]`

构造好之后再直接调用这个函数就可以求出数值解.

```python
f_python = sp.lambdify((x,y,z),f,'numpy')
```

```python
f_python(x=1,y=2,z=3)
```

    8


# 내삽법
Interpolation

예를 들어 실험을 통해 얻은 두 측정값 사이의 값을 추정하고 싶을 경우, 내삽법을 시도해 볼 수 있다.
For instance, to guess values between two measurements, we may try interpolation.

아래의 표를 살펴보자.
Let's take a look at the following table.

```python
# Import NumPy and matplotlib
import pylab as py

```

## `pandas` 소개
About `pandas`

`pandas`는 데이터 취급과 분석을 위한 파이썬 라이브러리 가운데 하나이다.
`pandas` is one of python libraries to handle and analyze data.

주로 *시리즈* `Series` 또는 *데이터 프레임* `DataFrame`에 데이터를 저장하는데, 각각 파이썬의 `dict` 또는 표와 비슷하다.

`pandas` mostly store data in `Series` or `DataFrame`, similar to `dict` of python or a table, respectively.

일반적으로 다음과 같이 불러들인다.

In general, it is imported as follows.

```python
# Import pandas for tables
import pandas as pd

```

```python
# What is this?
py.seed()

# Parameters
a = 0.5
b = 1.5

# x array
x_array = py.arange(5+0.5)

# True values of y
y_true = a * x_array + b

# contamination
noise = py.random(x_array.shape) - 0.5

# Measurement values
y_measurement = y_true + noise

# Organize data in table form
# https://stackoverflow.com/questions35160256
df = pd.DataFrame(
    {'$x$':x_array, '$y_{true}$':y_true, '$y_{measurement}$':y_measurement},
    columns=['$x$', '$y_{true}$', '$y_{measurement}$'],
)

# Plot data points
ax = df.plot.line(x='$x$', y='$y_{true}$')
df.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{measurement}$')

py.show()

```

```python
# Present the table
df

```

여기서 $0 \le x \le 1$ 구간의 $y$ 값을 알아보자.

Let's try to figure out $y$ values in the $0 \le x \le 1$ interval.

## 선형 내삽
Linear interpolation

### 수식화
Formulation

두 점 $(x_1, y_1)$, $(x_2, y_2)$ 을 지나는 직선의 방정식을 구할 수 있다.
We can formulate the straight line passing two points of $(x_1, y_1)$ and $(x_2, y_2)$.

```python
# Import symbolic processor module
import sympy as sy

# Initialize printing equations
sy.init_printing()

```

```python
# Declare symbols
x = sy.symbols('x')

# Multiple symbols using `:`
x1, x2 = sy.symbols('x1:3')
y1, y2 = sy.symbols('y1:3')

# Define slope
slope = (y2 - y1) / (x2 - x1)

# Define the straight line
y_interp = slope * (x - x1) + y1

# Present the equation
y_interp

```

$x$에 관해 정리하면 다음과 같을 것이다.
Or we may rewrite as follows.

```python
sy.collect(sy.expand(y_interp), x, sy.factor)

```

($0 \le x \le 1$ 구간에서) 임의의 $x_i$ 에 대응되는 $y_i$ 는 다음과 같이 구할 수 있다.
We can find $y_i$ for an arbitrary $x_i$ (within $0 \le x \le 1$ interval) as follows.

```python
# Declared x_i as a SymPy symbol
x_i = sy.symbols('x_i')

# Prepared a dictionary containing substitution pairs
substitution_dict = {
    # "substitute x with x_i"
    x: x_i,
    x1: x_array[0],
    x2: x_array[1],
    y1: y_measurement[0],
    y2: y_measurement[1],
}

# Substitution
y_i_sy = y_interp.subs(substitution_dict)

# Result of substitution
y_i_sy

```

프로그래밍 언어 구문을 생성하는 것도 가능하다.
SymPy may generate expressions in programming languages.

```python
python_code = sy.python(y_interp)
print(python_code)

```

```python
c_code = sy.ccode(y_interp)
print(c_code)

```

```python
fortran_code = sy.fcode(y_interp)
print(fortran_code)

```

### 실무
Practice

보통 `interp()` 함수를 이용한다.
Usually we call `interp()` function.

```python
# x values to interpolate
x_i = py.linspace(x_array[0], x_array[-1], 50+1)

# Interpolate
y_i = py.interp(x_i, x_array, y_measurement)

```

```python
# Plot data points
ax = df.plot.line(x='$x$', y='$y_{true}$')
df.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{measurement}$')

# Plot interpolation
ax.plot(x_i, y_i, '.', label='$y_{interp}$')

# Show legend table
py.legend(loc=0)

py.show()

```

### `pandas`

판다스의 데이터프레임도 간단한 내삽 기능이 있다.
`DataFrame` of `pandas` also has simple interpolation features.

```python
df_interp_nan = df.reindex(x_i)

```

```python
# http://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.interpolate.html
df_interp = df_interp_nan.interpolate(method='linear')

```

```python
# Plot data points
ax = df.plot.line(x='$x$', y='$y_{true}$')
# Plot interpolation
df_interp.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{interp}$', c='orange')
df.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{measurement}$')

# Show legend table
py.legend(loc=0)

py.show()

```

다음은 $y_{measurement}$ 의 히스토그램을 그린 것이다.
Following shows a histogram of $y_{measurement}$.

```python
df_interp['$y_{measurement}$'].hist()

```

## 3차 스플라인 곡선
Cubic spline curve

[스플라인](https://en.wiktionary.org/wiki/spline)은 얇고 긴 나무자를 말한다. 부드러운 곡선을 그리기 위해 사용했었다.
A [spline](https://en.wiktionary.org/wiki/spline) is a ruler made of a piece of thin and long rectangular wood.  Drafters used it draw a smooth curve.

[.png/1200px-Spline_(PSF).png)](https://en.wikipedia.org/wiki/Flat_spline)

여기서 "3차"는 내삽할 때 3차 다항식을 사용한다는 의미이다.

"Cubic" here means that we would interpolate using a 3rd order polynomial.

$$
y = a_0 x^3 + a_1 x^2 + a_2 x + a_3
$$

### SciPy

아래 셀에서는 3차 다항식을 이용하는 내삽기 `cubic_interp` 를 만들어서 사용한다.

The following cell first instantiate a cubic interpolator `cubic_interp` and use it.

```python
# https://www.scipy-lectures.org/intro/scipy.html#interpolation-scipy-interpolate

# Import interpolation subpackage
import scipy.interpolate as sn

cubic_interp = sn.interp1d(x_array, y_measurement, kind='cubic')
y_cubic = cubic_interp(x_i)

```

```python
# Plot data points
ax = df.plot.line(x='$x$', y='$y_{true}$')
df.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{measurement}$')

# Plot linear interpolation
ax.plot(x_i, y_i, '.', label='$y_{linear}$')

# Plot cubic spline curve
ax.plot(x_i, y_cubic, 'x', label='$y_{cubic}$')

# Show legend table
py.legend(loc=0)

py.show()

```

### pandas

```python
# http://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.interpolate.html
df_interp = df_interp_nan.interpolate(method='cubic')

```

```python
# Plot data points
ax = df.plot.line(x='$x$', y='$y_{true}$')
# Plot interpolation
df_interp.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{interp}$', c='orange')
df.plot.scatter(x='$x$', y='$y_{measurement}$', ax=ax, label='$y_{measurement}$')

# Show legend table
py.legend(loc=0)

py.show()

```

* 출력 양식 설정

* Output formatting

```python
pd.set_option('display.float_format', '{:.2g}'.format)

```

* 출력 행 수 설정

* Number of output lines

```python
pd.options.display.max_rows = 700

```

## 연습 문제
Exercises

도전 과제 1 : $0(^\circ)$ ~ $360(^\circ)$ 구간에서 10도 간격으로 $sin \theta^\circ $ 값의 표를 만드시오. 그래프로도 표시해보시오.

Try this 1 : Make a table of $sin \theta^\circ$ within $0(^\circ)$ ~ $360(^\circ)$ with interval of 10 degrees. Also plot it.

```python

```

도전 과제 2 : 위 표의 값을 이용하여 1도 간격으로 $sin \theta^\circ$ 값을 추정하시오.  `py.sin()` 결과와 그래프로 비교해 보시오.

Try this 2 : Estimate  $sin \theta^\circ$ values with interval of 1 degree using the values of the table above.  Compare with the result of `py.sin()` on a plot.

```python

```

## 유사 난수 발생기의 `seed`
`seed` of pseudorandom number generator

`py.random()` 등은 유사 난수 발생기이다.
Functions such as `py.random()` are pseudorandom number generators.

난수, 임의의 숫자와 비슷한 특징을 보이는 일련의 숫자열를 발생시키지만 정말로 무작위인 것은 아니다.
It would generate a sequence of numbers showing similar characteristics of random numbers, they are not truely random.[[wikipedia](https://en.wikipedia.org/wiki/Pseudorandom_number_generator)]

`seed`로 난수 발생을 통제할 수 있다.
We can control random number generation using `seed`.

```python
import pylab as py

```

다음 두 셀의 결과는 다를 것이다.
Following two cells would show different results.

```python
py.seed()
py.random([5,])

```

```python
py.seed()
py.random([5,])

```

다음 두 셀의 결과는 같을 것이다.
Following two cells would show the same results.

```python
seed = 2038011903
py.seed(seed)
py.random([5,])

```

```python
py.seed(seed)
py.random([5,])

```

## 기본 매개변수
Default Arguments

파이썬 함수를 정의할 때 매개변수에 기본값을 정해놓을 수 있다.
One may designate a default value when defining a python function.

```python
def ax_plus_b(x, a=2, b=3):

    print(f'x = {x}', end=', ')
    print(f'a = {a}', end=', ')
    print(f'b = {b}')

    return a * x + b

```

```python
ax_plus_b(1, 2, 3)

```

```python
ax_plus_b(1)

```

```python
ax_plus_b(1, 1)

```

```python
ax_plus_b(1, b=1)

```

## `sympy` 범위 기호 생성
Creating `sympy` symbols with range

```python
import sympy as sy

```

```python
# help(sy.symbols)

```

```python
sy.symbols('i:n')

```

```python
sy.symbols('z1:3')

```

```python
sy.symbols('w(:c)')

```

```python
sy.symbols('a(:2)(:2)')

```

## 참고문헌
References

* 맥키니 저, 김영근 역, 파이썬 라이브러리를 활용한 데이터 분석, 2판, 한빛미디어, 2019, ISBN 979-11-6224-190-5 ([코드와 데이터](https://github.com/wesm/pydata-book/))
Wes McKinney, Python for Data Analysis, 2nd Ed., O'Reilly, 2017. ([Code and data](https://github.com/wesm/pydata-book/))

## Final Bell
마지막 종

```python
# stackoverfow.com/a/24634221
import os
os.system("printf '\a'");

```

```python

```


# Clustering quality metrics

We can divide the clustering quality metrics into two groups: internal and external indicies. Internal indicies base only on the the clustered data without a clustered version that we could benchmark with. In other words, we don't have labelled/clustered data to compare with. In external indicies we have the information about the expected output and we are able to calculate metrics like accuracy, f1-score and other known from supervised learning.

In this section we focus on internal indicies only. We can group internal indicies into two groups:
- heterogeneity,
- homogeneity.
Four heterogeneity and two homogeneity methods are presented in this notebook.

## Libraries

Clustering quality metrics are implemented using only two libraries: ``numpy`` and ``math``, where the second one is used calculate the Euclidean distance.

```python
import numpy as np
from math import sqrt

def calculate_distance(x,v):
    return sqrt((x[0]-v[0])**2+(x[1]-v[1])**2)
```

## Data set

We compare two data sets, both are a results of HCM method. The first one is a HCM that divided the data set into two groups. We just restore the variable ``new_assignation_hcm`` and ``new_centers_hcm``. We have set two other variable of possible clustering for three groups and saved the results in ``new_assignation_hcm3`` and ``new_centers_hcm3``.

```python
%store -r new_assignation_hcm
%store -r new_centers_hcm
%store -r data_set

new_assignation_hcm = np.array(new_assignation_hcm)
new_centers_hcm = np.array(new_centers_hcm)

new_assignation_hcm3 = np.array([[0., 1., 0.],[0., 1., 0.], [0., 1., 0.],[0., 1., 0.], [0., 1., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., 1.], [0., 0., 1.], [1., 0., 0.]])
new_centers_hcm3 = np.array([[0.42239686, 0.38503185],[0.07858546, 0.17832272],[0.82907662, 0.97059448]])
```

## Homogeneity and heterogeneity

We have four separation measures $s_{1}(c_{i},c_{j})$, $s_{2}(c_{i},c_{j})$,
$s(s_{1})$ and $s(s_{2})$. The first two separation measures explain how far from each other the clusters are. We take the objects in each cluster and measure the distances between each object of two different clusters.

The metric $s_{1}$ can be calculate as following:
\begin{equation}
 s_{1}(c_{i},c_{j})=\frac{1}{n_{i}n_{j}}\sqrt{\sum_{x_{1},\in c_{i},x_{2}\in c_{j}}d^{2}(x_{1},x_{2})}.
\end{equation}
This measure can be implemented as below.

```python
def calculate_s_1(centers,assignation):
    s1 = []
    for center_1 in range(len(centers)):
        for center_2 in range(len(centers)):
            if center_1 == center_2:
                break
            ids_1 = np.where(assignation[:, center_1] == 1)[0]
            ids_2 = np.where(assignation[:, center_2] == 1)[0]
            elements_1 = data_set[ids_1]
            elements_2 = data_set[ids_2]
            s_1 = 1.0 / (len(ids_1) * len(ids_2))
            for element_1 in elements_1:
                for element_2 in elements_2:
                    s_1 = s_1 * sqrt(calculate_distance(element_1, element_2) ** 2)
            s1.append(s_1)
    return s1
```

We take two objects, each from different cluster and calculate the power distance measure. Next, we sum all the distances from object of two clusters and calculate a square root of it. The value is then divided by the multiplication of the counts of objects in both clusters. The higher value is better.

```python
s1_2 = calculate_s_1(new_centers_hcm,new_assignation_hcm)
s1_3 = calculate_s_1(new_centers_hcm3,new_assignation_hcm3)

print(s1_2)
print(s1_3)
```

    [0.10062820443387574]
    [0.0007587103186922478, 0.12913857784026206, 0.3030325350305444]

The best clusters are the first compared to the second in the thre group clustering.

The second separation measure is about the distance between two centers only:
\begin{equation}
 s_{2}(c_{i},c_{j})=d(c_{i},c_{j}).
\end{equation}
It is the simplest measure and it can be implemented as below.

```python
def calculate_s_2(centers):
    s2 = []
    for center_1 in range(len(centers)):
        for center_2 in range(len(centers)):
            if center_1 == center_2:
                break
            s2.append(calculate_distance(centers[center_1], centers[center_2]))
    return s2
```

The same as the previous metric, higher values means a better cluster.

```python
s2_2 = calculate_s_2(new_centers_hcm)
s2_3 = calculate_s_2(new_centers_hcm3)

print(s2_2)
print(s2_3)
```

    [1.0361961303876361]
    [0.40116697670087065, 0.7129319889345509, 1.0912980907761376]

A different metric is the $\sigma_{1}$ which does not take the centers into consideration. It measures the distances between each object within a cluster. The smaller value here the better cluster it is. The metric is defined as:

\begin{equation}
 \sigma_{1}(c_{i})=\frac{1}{m}\sum_{x_{1}, x_{2}\in c_{i}}d^{2}(x_{1},x_{2}),
\end{equation}

where the $m$ is defined as

\begin{equation}
 m=\frac{(n_{i}-1)n_{i}}{2}.
\end{equation}

```python
def calculate_sigma_1(assignation):
    sigma_1 = []
    unique_labels = len(assignation[0])
    for label_id in range(unique_labels):
        ids = np.where(assignation[:, label_id] == 1)[0]
        if len(ids) == 1:
            sigma_1.append(1.0)
            continue
        else:
            m = (len(ids) - 1.0) * len(ids) / 2.0
        elements = data_set[ids]
        sigma = (1.0 / m)
        for element_x_1 in range(len(elements)):
            for element_x_2 in range(len(elements)):
                if element_x_1 == element_x_2:
                    continue
                distance = calculate_distance(elements[element_x_1], elements[element_x_2])
                if distance != 0:
                    sigma = sigma + (distance ** 2)
        sigma_1.append(sigma)
    return sigma_1
```

The results are given for each cluster. That's why we have only two values in the first cases, and three in the second list. Lower value means a better cluster.

```python
sigma1_2 = calculate_sigma_1(new_assignation_hcm)
sigma1_3 = calculate_sigma_1(new_assignation_hcm3)

print(sigma1_2)
print(sigma1_3)
```

    [2.775635895144805, 0.9686234538734891]
    [1.0, 0.7496360155982489, 0.9686234538734891]

In the metric $s(s_{1})$ we take two previously calculated values:
\begin{equation}
 s(s_{1})=\sum_{i,j=1;j\neq i}^{K}\frac{s_{1}(c_{i},c_{j})}{\sigma_{1}(c_{i})}.
\end{equation}
This measure is a bit more complex and it measure the relation between the distances of objects within a cluster and distnaces between objects in two clusters. Higher values means a better cluster, but it takes the relation between two clusters as a result.

```python
def calculate_s_s_1(s1, sigma_1):
    s_1_sum = 0.0
    sigma_1_sum = 0.0
    for s_1 in s1:
        s_1_sum = s_1_sum + s_1
    for sigma_1 in sigma_1:
        sigma_1_sum = sigma_1_sum + sigma_1
    s_s1 = s_1_sum / sigma_1_sum
    return s_s1
```

The results below makes it clear that three clusters solution is better than two. Usually it is that increasing the number of clusters increase the value of this metric.

```python
s11_2 = calculate_s_s_1(s1_2,sigma1_2)
s11_3 = calculate_s_s_1(s1_3,sigma1_3)

print(s11_2)
print(s11_3)
```

    0.026875329685765333
    0.1592672914604556

The metric $\sigma_{2}$ measures the distances between objects within a cluster and the center of this cluster. It is defined as:
\begin{equation}
 \sigma_{2}(c_{i})=\frac{1}{n_{i}}\sum_{x\in c_{i}}d^{2}(x,c_{i}).
\end{equation}
It can be implemented as:

```python
def calculate_sigma_2(centers,assignation):
    sigma_2 = []
    for center_id in range(len(centers)):
        ids = np.where(assignation[:, center_id] == 1)[0]
        elements = data_set[ids]
        sigma = 1.0 / len(ids)
        for element_id in range(len(elements)):
            distance = calculate_distance(elements[element_id], centers[center_id])
            if distance != 0:
                sigma = sigma + (distance) ** 2
        sigma_2.append(sigma)
    return sigma_2
```

Lower value means a better cluster. We get a list of values for each cluster. In our case the second cluster in the three cluster approach is the best one.

```python
sigma2_2 = calculate_sigma_2(new_centers_hcm,new_assignation_hcm)
sigma2_3 = calculate_sigma_2(new_centers_hcm3,new_assignation_hcm3)

print(sigma2_2)
print(sigma2_3)
```

    [0.3377154891089826, 0.4392150200900259]
    [1.0, 0.22358077907763185, 0.4392150200900259]

## Other internal indices

The Dunn index says what is the relation between the minimal distance of objects within a cluster and the maximum distance of objects within a cluster. It gives a better understanding of how far are the objects from each other. It can be easily calculated as a quotient of two distances:
\begin{equation}
 C=\frac{d_{min}}{d_{max}},
\end{equation}
where the equations of $d_{max}$ and $d_{min}$ are like following:
\begin{equation}
 d_{max}=\max_{1\leq k\leq K}D_{k},\\
\end{equation}
and
\begin{equation}
 d_{min}=\min_{k\neq k'} d_{k}.
\end{equation}

Both distances are just the minimum and maximum euclidean distances between objects. The minimum distance is a measure of two object that are in different clusters:
\begin{equation}
 d_{k}=\min_{i,j\in I_{k}; i\neq j}d(x_{i}^{(k)}-x_{j}^{(k')}).
\end{equation}
The clusters are marked with $k$ and $k'$. The maximum distance takes the distance of two objects within a cluster:
\begin{equation}
 D_{k}=\max_{i,j\in I_{k}; i\neq j}d(x_{i}^{(k)}-x_{j}^{(k)}).
\end{equation}
It can be implemeneted as:

```python
def dunn_index(assignation):
    minimum_distance = 1
    maximum_distance = 0
    unique_labels = np.unique(assignation[0])
    for label_id_1 in range(len(unique_labels)):
        ids_1 = np.where(assignation[:, label_id_1] == 1)[0]
        for label_id_2 in range(len(unique_labels)):
            if label_id_1 == label_id_2:
                break
            ids_2 = np.where(assignation[:, label_id_2] == 1)[0]
            for element_1 in data_set[ids_1]:
                for element_2 in data_set[ids_2]:
                    distance = calculate_distance(element_1, element_2)
                    if distance > maximum_distance:
                        maximum_distance = distance
                    if distance < minimum_distance:
                        minimum_distance = distance
    dunn_index = minimum_distance / maximum_distance
    return dunn_index
```

The values of Dunn index for our examples are given below. In this case a higher values means a better clustering result.

```python
dunn2 = dunn_index(new_assignation_hcm)
dunn3 = dunn_index(new_assignation_hcm3)

print(dunn2)
print(dunn3)
```

    0.48534159949891914
    0.7224828783672833

```python

```


```python
from sympy.interactive import printing
printing.init_printing(use_latex = True)
import numpy as np
import scipy as sc
import sympy as sp
import matplotlib as mp
import matplotlib.pyplot as plt
from numpy.linalg import norm
from mpl_toolkits.mplot3d import axes3d
```

$PROBLEM$ $1$

```python
A=np.array([2,-3,7])
B=np.array([-4,1,0])
C=np.array([5,-1,9])
D=np.array([0,0,2])
```

```python
print("Part A")
```

    Part A

```python
print(np.dot(A,B), "is the dot product of A and B")
print(np.dot(C,D), "is the dot product of C and D")

```

    -11 is the dot product of A and B
    18 is the dot product of C and D

```python
print("\n\n\n\nPart B ")
```

    Part B

```python
R=np.cross(A,B)
print(R, "is the resultant vector of A cross B")
print(norm(R),"is the magnitude of A cross B")
i=np.array([-7/norm(R),0,0])
j=np.array([0,-28/norm(R),0])
k=np.array([0,0,-10/norm(R)])
x=np.arccos(np.dot(R,i)/(norm(R)*norm(i)))
y=np.arccos(np.dot(R,j)/(norm(R)*norm(j)))
z=np.arccos(np.dot(R,k)/(norm(R)*norm(k)))
print(x,y,z)
# print(np.degrees(x),"degrees angle with x-axis")
# print(np.degrees(y),"degress angle with y-axis")
# print(np.degrees(z),"degrees angle with z-axis")
```

    [ -7 -28 -10] is the resultant vector of A cross B
    30.54504869860253 is the magnitude of A cross B
    1.339571719189226 0.41110738580715916 1.2372612783481713

```python
print("\n\n\nPart C\n")
V=np.cross(B,D)
U=np.array([2/norm(V),8/norm(V),0/norm(V)])
print(U, "is the Unit Vector along BxD")
```

    Part C

    [0.24253563 0.9701425  0.        ] is the Unit Vector along BxD

```python
print("\n\n\n\nPart D\n")
```

    Part D

```python
a=print(norm(A))
d=print(norm(B))
c=print(norm(C))
d=print(norm(D))
print ("The vector with the largest magnitude is C \n")
```

    7.874007874011811
    4.123105625617661
    10.344080432788601
    2.0
    The vector with the largest magnitude is C

```python
print("\n\n\n\nQUESTION 2 \n")
```

    QUESTION 2

```python
print("\n\n\n\nPart A\n")
```

    Part A

```python
a=np.array([[1,0,2],[2,1,3],[1,3,1],[4,1,0,]])
b=np.array([[2,0,1,2],[1,2,1,3],[5,3,1,4]])
print(a,"\n")
print(b,"\n")
print(a@b)
```

    [[1 0 2]
     [2 1 3]
     [1 3 1]
     [4 1 0]]

    [[2 0 1 2]
     [1 2 1 3]
     [5 3 1 4]]

    [[12  6  3 10]
     [20 11  6 19]
     [10  9  5 15]
     [ 9  2  5 11]]

```python
print("\n\n\n\nPart B\n")
print("Yes it possible to do the multiplication of a.aT because a transpose is just change of rows and columns wich is vali to results.\n")
print("Here is the result of aaT\n")
V=a@a.T
print(V)
```

    Part B

    Yes it possible to do the multiplication of a.aT because a transpose is just change of rows and columns wich is vali to results.

    Here is the result of aaT

    [[ 5  8  3  4]
     [ 8 14  8  9]
     [ 3  8 11  7]
     [ 4  9  7 17]]

```python
print("\n\n\n\nPart C\n")
U=(a@b).T
W=(b.T)@(a.T)
print(U,"\n")
print(W,"\n\n Thus we can see that (ab)T=bT@aT are equal")

```

    Part C

    [[12 20 10  9]
     [ 6 11  9  2]
     [ 3  6  5  5]
     [10 19 15 11]]

    [[12 20 10  9]
     [ 6 11  9  2]
     [ 3  6  5  5]
     [10 19 15 11]]

     Thus we can see that (ab)T=bT@aT are equal

```python
print("\n\n\n\nPart D\n")
c=np.array([[1,0,2],[2,1,3],[1,3,4]])
d=np.array([[2,0,1],[1,2,1],[5,0,1]])
V=np.linalg.inv(c@d)
W=np.linalg.inv(d)@np.linalg.inv(c)
print(W,"\n")
print(V)

```

    Part D

    [[ 0.66666667 -0.6         0.2       ]
     [ 0.33333333 -0.7         0.4       ]
     [-2.33333333  2.4        -0.8       ]]

    [[ 0.66666667 -0.6         0.2       ]
     [ 0.33333333 -0.7         0.4       ]
     [-2.33333333  2.4        -0.8       ]]

```python
print("\n\n\n\npart E\n")
print(np.linalg.inv(c).T)
print("\n")
print(np.linalg.inv(c.T))

```

    part E

    [[-1.  -1.   1. ]
     [ 1.2  0.4 -0.6]
     [-0.4  0.2  0.2]]

    [[-1.  -1.   1. ]
     [ 1.2  0.4 -0.6]
     [-0.4  0.2  0.2]]

```python
print("\n\n\n\n\nQUESTION 3 \n")
print("2x1 + 0.7x2 - 3.5x3 + 7x4 - 0.5x5 = 2 \n")
print("-1.2x1 + 2.7x2 + 0x3 - 3x4 - 2.5x5 = -17 \n")
print("x1 + x2 - x3 - x4 + x5 = 5 \n")
print("2.9x1 + 0x2 + 0x3 + 0x4 + 7.5x5 = 0 \n")
print("0x1 + 0x2 + 0x3 - 2.7x4 - 5.5x5 = -11 \n")
```

    QUESTION 3

    2x1 + 0.7x2 - 3.5x3 + 7x4 - 0.5x5 = 2

    -1.2x1 + 2.7x2 + 0x3 - 3x4 - 2.5x5 = -17

    x1 + x2 - x3 - x4 + x5 = 5

    2.9x1 + 0x2 + 0x3 + 0x4 + 7.5x5 = 0

    0x1 + 0x2 + 0x3 - 2.7x4 - 5.5x5 = -11

```python
E=np.array([[2,0.7,-3.5,7,-0.5],[-1.2,2.7,0,-3,-2.5],[1,1,-1,-1,1],[2.9,0,0,0,7.5],[0,0,1.8,-2.7,-5.5]])
G=np.array([[2,-17,5,0,-11]]).T
H=np.linalg.inv(E)@G
print(E,"\n")
print(G,"\n")
print(H,"\n")
print("Values are x1=-0.56, x2-11.34, x3=-12.19, x4=-4.49, x5=0.21")

```

    [[ 2.   0.7 -3.5  7.  -0.5]
     [-1.2  2.7  0.  -3.  -2.5]
     [ 1.   1.  -1.  -1.   1. ]
     [ 2.9  0.   0.   0.   7.5]
     [ 0.   0.   1.8 -2.7 -5.5]]

    [[  2]
     [-17]
     [  5]
     [  0]
     [-11]]

    [[ -0.56501824]
     [-11.34361865]
     [-12.19151891]
     [ -4.49864426]
     [  0.21847372]]

    Values are x1=-0.56, x2-11.34, x3=-12.19, x4=-4.49, x5=0.21

```python
print ("Check Calculation of RHS with the LHS\n")
print(2*-0.565 + 0.7*-11.34 - 3.5*-12.19 + 7*-4.49 - 0.5*0.21, " == 2 \n")
print(-1.2*-0.565 + 2.7*-11.34 - 0*-12.19 + -3*-4.49 - 2.5*0.21, " == -17 \n")
print(1*-0.565 + 1*-11.34 - 1*-12.19 - 1*-4.49 + 1*0.21, " == 5 \n")
print(2.9*-0.565 + 7.5*0.21, " == 0 \n")
print(1.8*-12.19 + -2.7*-4.49 - 5.5*0.21, " == -11 \n")
```

    Check Calculation of RHS with the LHS

    2.0620000000000016  == 2

    -16.994999999999997  == -17

    4.985  == 5

    -0.06349999999999989  == 0

    -10.973999999999998  == -11


# Delta Method

Code for reproducing all the results in the paper _The Delta-method and influence function in epidemiology: a reproducible tutorial_

```python
%pip install --upgrade --quiet --force-reinstall "sympy>=1.10.0"
```

```python
#We'll use the sympy library for symbolic computation of variances and expected values
import sympy
from sympy import *
from sympy.stats import Variance, Expectation, Covariance, Binomial
from sympy.stats.rv import RandomSymbol
sympy.__version__   #version of sympy should be >=1.10
```

    '1.10'

## Introduction to Sympy
[Sympy](https://docs.sympy.org/latest/guides/getting_started/install.html) is a computer algebra system for symbolic calculation. In order to use it you need to declare variables using `Symbol`:

```python
x = Symbol("x")
y = Symbol("y")
```

You can use the `Symbol`s to generate algebraic expressions such as:

```python
squarexy = pow((x + y),2) #(x + y)^2
```

which can be expanded, derived and integrated:

```python
expand(squarexy)
```

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

```python
diff(squarexy,x)
```

$\displaystyle 2 x + 2 y$

```python
integrate(squarexy, x)
```

$\displaystyle \frac{x^{3}}{3} + x^{2} y + x y^{2}$

Random variables can be declared with `RandomSymbol`:

```python
#Declaration of random variables can be made via RandomSymbol and their
#variance and expected values can be estimated and simplified
X                = RandomSymbol('X')

#Properties of expectation can be computed
expectation_of_x = Expectation(3*X + 2)
expectation_of_x.expand()
```

$\displaystyle 2 + 3 \operatorname{E}\left[X\right]$

```python
#As well as properties of variance
variance_of_x    = Variance(3*X + 2)
variance_of_x.expand()
```

$\displaystyle 9 \operatorname{Var}\left(X\right)$

## Delta-method for the mean

First we declare the constants: $\mu$, $\sigma^2$ and $n$ corresponding to the mean, variance and sample size. We also declare the random variable $\bar{X}$ corresponding to the sample mean which has mean $\mu$ and variance $\sigma^2/n$

```python
#Declaration of constants (Symbol) and random variables (RandomSymbol)
mu       = Symbol('mu')
sigmasq  = Symbol('sigma^2', positive = True) #Variance of x
n        = Symbol('n', positive = True, integer = True)
Xbar     = RandomSymbol('Xbar')
```

We write the function $\phi$ as a function of $\mu$ _i.e._ $\phi(\mu) =  \mu$:

```python
#Declaring phi as a function of mu
def phi(mu):
    return mu
```

We calculate the derivative: $\frac{\partial \phi}{\partial \mu}$:

```python
#Obtaining the derivative
classical_derivative = derive_by_array(phi(mu), mu)
```

We then calculate the direction vector for the Hadamard derivative $v = \bar{X} - \mu$:

```python
v = Xbar - mu
```

The Hadamard derivative in the direction of $v$ (that is $ \partial_{\hat{\theta} - \theta}$) is thus:

```python
hadamard = classical_derivative*v
```

And the approximation is obtained by computing the right side of:
$$
\phi(\hat{\theta}) = \phi(\theta)  + \partial_{\hat{\theta} - \theta} \phi(\theta)
$$

```python
phi(mu) + hadamard
```

$\displaystyle \bar{X}$

Hence the Delta-method's approximation to the variance is:

```python
delta_variance = Variance(phi(mu) + hadamard).expand()
```

and we know from classical inference that $\text{Var}(\bar{X}) = \frac{\sigma^2}{n}$

```python
delta_variance.subs(Variance(Xbar), sigmasq/n)
```

$\displaystyle \frac{\sigma^{2}}{n}$

## Delta-method for the variance of the ratio of two sample means

Let $\{X_1, \dots, X_n\}$ denote a random sample of variables $X$ with mean $\mu_X$ and variance $\sigma^2_X$ and  $\{Y_1, \dots, Y_n\}$ denote a random sample of variables $Y$ with mean $\mu_Y$ and variance $\sigma^2_Y$ we are interested in approximating the variance of

$$
\phi(\mu_X,\mu_Y) = \frac{\mu_X}{\mu_Y}
$$

to so we declare the symbols $\bar{X},\bar{Y},\mu_X,\mu_Y$ and the function $\phi$:

```python
#Declaration of variables
mu_x       = Symbol('mu_x')
mu_y       = Symbol('mu_y')
n          = Symbol('n', positive=True, integer = True) #Sample size of X and Y
sigmasq_x  = Symbol('sigma^2_X', positive = True)
sigmasq_y  = Symbol('sigma^2_Y', positive = True)
sigma_xy   = Symbol('sigma_xy') #Covariance
Xbar       = RandomSymbol('Xbar')
Ybar       = RandomSymbol('Ybar')

#Declaration of function
def phi(mu_x, mu_y):
  return mu_x/mu_y
```

We calculate the derivative (in this case, gradient) in the direction of
$$
v = \begin{pmatrix}
    \bar{X} - \mu_X\\
    \bar{Y} - \mu_Y
    \end{pmatrix}
$$

```python
#Obtain the direction vector
v = Matrix([Xbar - mu_x, Ybar - mu_y])

#And calculate the gradient
grad = derive_by_array(phi(mu_x,mu_y), [mu_x, mu_y])
grad = Matrix(grad)

#Thus calculating the Hadamard (directional) derivative:
hadamard = grad.dot(v)
hadamard
```

$\displaystyle - \frac{\mu_{x} \left(- \mu_{y} + \bar{Y}\right)}{\mu_{y}^{2}} + \frac{- \mu_{x} + \bar{X}}{\mu_{y}}$

The variance of the hadamard derivative (influence function) is estimated as follows:

```python
variance_expanded = Variance(hadamard).expand().simplify()
variance_expanded
```

$\displaystyle \frac{\mu_{x}^{2} \operatorname{Var}\left(\bar{Y}\right) - 2 \mu_{x} \mu_{y} \operatorname{Cov}\left(\bar{X}, \bar{Y}\right) + \mu_{y}^{2} \operatorname{Var}\left(\bar{X}\right)}{\mu_{y}^{4}}$

We can further simplify by substituting the variances of $\bar{X}$ and $\bar{Y} as well as the covariances$:

```python
variance_expanded = variance_expanded.subs(Covariance(Xbar,Ybar), sigma_xy/n)
variance_expanded = variance_expanded.subs(Variance(Xbar), sigmasq_x/n)
variance_expanded = variance_expanded.subs(Variance(Ybar), sigmasq_y/n)

variance_expanded.simplify()
```

$\displaystyle \frac{\mu_{x}^{2} \sigma^{2}_{Y} - 2 \mu_{x} \mu_{y} \sigma_{xy} + \mu_{y}^{2} \sigma^{2}_{X}}{\mu_{y}^{4} n}$

## Delta-method for the risk ratio
Consider the risk ratio for two probabilities defined by:
$$
RR(p_1,p_2) = \frac{p_1}{p_2}
$$
and the transformation $\phi$ given by:
$$
\phi(p_1,p_2) = \log\big(RR(p_1,p_2)\big)
$$
In this case, a random sample of $N_1$ elements of one category and $N_2$ elements of a second (independent) category with respective probabilities $p_1$ and $p_2$ result in estimators $\hat{p}_1$ and $\hat{p}_2$ which we declare:

```python
#Declaration of variables
p_1       = Symbol('p_1')
p_2       = Symbol('p_2')
p_1hat    = RandomSymbol('phat_1')
p_2hat    = RandomSymbol('phat_2')
N_1       = Symbol('N_1', positive=True, integer = True) #Sample size asssociated to p1
N_2       = Symbol('N_2', positive=True, integer = True) #Sample size asssociated to p2

#Declaration of relative risk function
def RR(p_1, p_2):
  return p_1/p_2

#Declaration of phi for delta method
def phi(p_1, p_2):
  return log(RR(p_1, p_2));
```

The derivative is given by:

```python
#Obtain the direction vector
v = Matrix([p_1hat - p_1, p_2hat - p_2])

#And calculate the gradient
grad = derive_by_array(phi(p_1,p_2), [p_1, p_2])
grad = Matrix(grad)

#Thus calculating the Hadamard (directional) derivative:
hadamard = grad.dot(v)
hadamard
```

$\displaystyle - \frac{- p_{2} + \hat{p}_{2}}{p_{2}} + \frac{- p_{1} + \hat{p}_{1}}{p_{1}}$

And the variance where we use that $\text{Var}(\hat{p}_i) = p_i(1-p_i)/N_i$ and that the covariance is zero due to independence

```python
variance_expanded = Variance(hadamard).expand().simplify()
variance_expanded
```

$\displaystyle \frac{\operatorname{Var}\left(\hat{p}_{2}\right)}{p_{2}^{2}} - \frac{2 \operatorname{Cov}\left(\hat{p}_{1}, \hat{p}_{2}\right)}{p_{1} p_{2}} + \frac{\operatorname{Var}\left(\hat{p}_{1}\right)}{p_{1}^{2}}$

Which can be simplified into:

```python
variance_expanded = variance_expanded.subs(Covariance(p_1hat,p_2hat), 0.0)
variance_expanded = variance_expanded.subs(Variance(p_1hat), p_1*(1 - p_1)/N_1)
variance_expanded = variance_expanded.subs(Variance(p_2hat), p_2*(1 - p_2)/N_2)

variance_expanded
```

$\displaystyle \frac{1 - p_{2}}{N_{2} p_{2}} + \frac{1 - p_{1}}{N_{1} p_{1}}$

## Counterexample

Consider the attributable fraction among the exposed for an exposure level $x$ given by:
$$
\textrm{AF}_e(x) = \dfrac{RR(\theta,x)-1}{RR(\theta,x)}.
$$
we compute the expansion around $x = 0$:

```python
#Declaration of variables
x         = Symbol('x')
theta     = Symbol('theta', positive = True)

#Declaration of relative risk function
def RR(x, theta):
  return exp(theta/x)

#Declaration of phi for delta method
def AF(x, theta):
  return (RR(x,theta) - 1)/RR(x, theta);

#The derivative at x = 0 exists and is zero for positive theta
derivative = diff(AF(x,theta),x)
limit(derivative, x, 0)
```

$\displaystyle 0$

```python
#This happens for any order (n) of the Taylor expansion as you can verify;
series(AF(x, 1), x0 = 0, n = 10).removeO()
```

$\displaystyle 0$

## Higher order Delta-method
For example, if we wish to estimate the Taylor series for $p\cdot(1-p)$ around $1/2$:

```python
#Get the variables
p    = Symbol('p', positive=True)
phat = RandomSymbol('phat')

#Define the function
def phi(p):
  return p*(1 - p)

#3rd order Taylor series
taylorseries = series(phi(phat), x0 = 1/2, n = 3).removeO()

#get the variance
variance_taylor = Variance(taylorseries).expand()
variance_taylor
```

$\displaystyle \operatorname{Var}\left(\left(\hat{p} - 0.5\right)^{2}\right)$

It is well known that for a sample of size $n$, the variable $B_n = n \cdot \hat{p}$ corresponds to a $\text{Binomial}(n,p)$ this allows us to rewrite the expression above in terms of $B$ to improve the estimation.

As an example we'll consider $n = 100$ (for now `sympy` can't evaluate with generic $n$)

```python
#CAREFUL! THIS TAKES A WHILE TO EVALUATE
#Declare the variable
n   = 100
B   = Binomial("B_n", n, p)

#Substitute phat = B_n / n
sympy.stats.variance(taylorseries.subs(phat, B/n)).simplify()
```

Finally, we compute the value:


# Manifold Assessment

In this notebook we demonstrate tools that may be used for assessing manifold quality and dimensionality as well as comparing manifolds (parameterizations) in terms of representing dependent variables of interest.

```python
import numpy as np
import matplotlib.pyplot as plt
from PCAfold import compute_normalized_variance, PCA, normalized_variance_derivative,\
find_local_maxima, plot_normalized_variance, plot_normalized_variance_comparison,\
plot_normalized_variance_derivative, plot_normalized_variance_derivative_comparison, random_sampling_normalized_variance

```

Here we are creating a two-dimensional manifold to assess with a dependent variable.
Independent variables $x$ and $y$ and dependent variable $f$ will be defined as

\begin{align}
  x &= e^{g} \cos^2(g) \\
  y &= \cos^2(g) \\
  f &= g^3+g \\
\end{align}
for a grid $g$ between [-0.5,1].

```python
npts = 1001
grid = np.linspace(-0.5,1.,npts)

x = np.exp(grid)*np.cos(grid)**2
y = np.cos(grid)**2

f = grid**3+grid
depvar_name = 'f' # dependent variable name

plt.scatter(x, y, c=f, s=5, cmap='rainbow')
plt.colorbar()
plt.grid()
plt.xlabel('x')
plt.ylabel('y')
plt.title('colored by f')
plt.show()

```

We now want to assess the manifold in one and two dimensions using `compute_normalized_variance`. In order to use this function, the independent and dependent variables must be arranged into two-dimensional arrays size npts by number of variables. This is done in the following code.

```python
indepvars = np.vstack((x, y)).T
depvars = np.expand_dims(f, axis=1)
print('indepvars shape:', indepvars.shape, '\n  depvars shape:', depvars.shape)

```

    indepvars shape: (1001, 2)
      depvars shape: (1001, 1)

We can now call `compute_normalized_variance` on both the two-dimensional manifold and one-dimensional slices of it in order to assess the true dimensionality of the manifold (which should be two in this case).
A normalized variance is computed at various bandwidths (Gaussian kernel filter widths) which can provide indications of overlapping states in the manifold (or non-uniqueness) as well as indications of how spread out the dependent variables are. A unique manifold with large spread in the data should better facilitate building models for accurate representations of the dependent variables of interest. Details on the normalized variance equations may be found in the documentation.

The bandwidths are applied to the independent variables after they are centered and scaled inside a unit box (by default). The bandwidth values may be computed by default according to interpoint distances or may be specified directly by the user.

Below is a demonstration of using default bandwidth values and plotting the resulting normalized variance.

```python
orig2D_default  = compute_normalized_variance(indepvars, depvars, [depvar_name])

plt = plot_normalized_variance(orig2D_default)
plt.show()

```

Now we will define an array for the bandwidths in order for the same values to be applied to our manifolds of interest.

```python
bandwidth = np.logspace(-6,1,100) # array of bandwidth values

# one-dimensional manifold represented by x
orig1Dx = compute_normalized_variance(indepvars[:,:1], depvars, [depvar_name], bandwidth_values=bandwidth)
# one-dimensional manifold represented by y
orig1Dy = compute_normalized_variance(indepvars[:,1:], depvars, [depvar_name], bandwidth_values=bandwidth)
# original two-dimensional manifold
orig2D  = compute_normalized_variance(indepvars,       depvars, [depvar_name], bandwidth_values=bandwidth)

```

The following plot shows the normalized variance calculated for the dependent variable on each of the three manifolds. A single smooth rise in the normalized variance over bandwidth values indicates a unique manifold. Multiple rises, as can be seen in the one-dimensional manifolds, indicate multiple scales of variation. In this example, those smaller scales can be attributed to non-uniqueness introduced through the projection into one dimension. A curve that rises at larger bandwidth values also indicates more spread in the dependent variable over the manifold. Therefore the desired curve for an optimal manifold is one that has a single smooth rise that occurs at larger bandwidth values.

```python
plt = plot_normalized_variance_comparison((orig1Dx, orig1Dy, orig2D), ([], [], []), ('Blues', 'Reds', 'Greens'), title='Normalized variance for '+depvar_name)
plt.legend(['orig,1D_x', 'orig,1D_y', 'orig,2D'])
plt.show()

```

In order to better highlight the fastest changes in the normalized variance, we look at a scaled derivative over the logarithmically scaled bandwidths which relays how fast the variance is changing as the bandwidth changes. Specifically we compute $\hat{\mathcal{D}}(\sigma)$, whose equation can be found in the documentation. Below we show this quantity for the original two-dimensional manifold.

We see a single peak in $\hat{\mathcal{D}}(\sigma)$ corresponding to the single rise in $\mathcal{N}(\sigma)$ pointed out above. The location of this peak gives an idea of the feature sizes or length scales associated with variation in the dependent variable over the manifold.

```python
plt = plot_normalized_variance_derivative(orig2D)
plt.show()

```

We can also plot a comparison of these peaks using `plot_normalized_variance_derivative_comparison` for the three manifold representations discussed thus far. In the plot below, we can see that the two one-dimensional projections have two peaks in $\hat{\mathcal{D}}(\sigma)$ corresponding to the two humps in the normalized variance. This clearly shows that the projections are introducing a significant scale of variation not present on the original two-dimensional manifold. The locations of these peaks indicate the feature sizes or scales of variaiton present in the dependent variable on the manifolds.

```python
plt = plot_normalized_variance_derivative_comparison((orig1Dx, orig1Dy, orig2D), ([],[],[]), ('Blues', 'Reds','Greens'))
plt.legend(['orig,1D_x', 'orig,1D_y', 'orig,2D'])
plt.show()

```

We can also break down the analysis of these peaks to determine the $\sigma$ where they occur. The `normalized_variance_derivative` function will return a dictionary of $\hat{\mathcal{D}}(\sigma)$ for each dependent variable along with the corresponding $\sigma$ values. The `find_local_maxima` function can then be used to report the locations of the peaks in $\hat{\mathcal{D}}(\sigma)$ along with the peak values themselves. In order to properly analyze these peaks, we leave the `logscaling` parameter to its default True value. We can also set `show_plot` to True to display the peaks found. This is demonstrated for the one-dimensional projection onto x below.

```python
orig1Dx_derivative, orig1Dx_sigma = normalized_variance_derivative(orig1Dx)
orig1Dx_peak_locs, orig1Dx_peak_values = find_local_maxima(orig1Dx_derivative[depvar_name], orig1Dx_sigma, show_plot=True)
print('peak locations:', orig1Dx_peak_locs)
print('peak values:', orig1Dx_peak_values)

```

In this example, we know in the case of the one-dimensional projections that non-uniqueness or overlap is introduced in the dependent variable representation. This shows up as an additional peak in $\hat{\mathcal{D}}(\sigma)$ compared to the original two-dimensional manifold. In general, though, we may not know whether that additional scale of variation is due to non-uniqueness or is a new characteristic feature from sharpening gradients. We can analyze sensitivity to data sampling in order to distinguish between the two.

As an example, we will analyze the projection onto x. We can use the `random_sampling_normalized_variance` to compute the normalized variance for various random samplings based on the provided `sampling_percentages` argument. We can also specify multiple realizations through the `n_sample_iterations` argument, which will be averaged for returning $\hat{\mathcal{D}}(\sigma)$. We will test 100%, 50%, and 25% specified as [1., 0.5, 0.25]. Note that specifying 100% returns the same result as calling compute_normalized variance on the full dataset as we did above.

```python
pctdict, pctsig, _ = random_sampling_normalized_variance([1., 0.5, 0.25],
                                                             indepvars[:,:1],
                                                             depvars,
                                                             [depvar_name],
                                                             bandwidth_values=bandwidth,
                                                             n_sample_iterations=5)

```

    sampling 100.0 % of the data
      iteration 1 of 5
      iteration 2 of 5
      iteration 3 of 5
      iteration 4 of 5
      iteration 5 of 5
    sampling 50.0 % of the data
      iteration 1 of 5
      iteration 2 of 5
      iteration 3 of 5
      iteration 4 of 5
      iteration 5 of 5
    sampling 25.0 % of the data
      iteration 1 of 5
      iteration 2 of 5
      iteration 3 of 5
      iteration 4 of 5
      iteration 5 of 5

We then plot the result below and report the peak locations for the two dominant peaks. We can see that the peak at the larger $\sigma$ isn't very sensitive to data sampling. It remains around 0.5. The peak at smaller $\sigma$ though experiences a shift to larger $\sigma$ as less data is included (lower percent sampling). This is because variation from non-uniqueness is much more sensitive to data spacing than characteristic feature variation. We would therefore conclude that the second scale of variation introduced by the projection onto x is due to non-uniqueness, not a characteristic feature size, and therefore the projection is unacceptable. This confirms what we already knew from the visual analysis.

```python
peakthreshold = 0.4

for pct in pctdict.keys():
    plt.semilogx(pctsig, pctdict[pct][depvar_name], '--', linewidth=2, label=pct)
    peak_locs, peak_vals = find_local_maxima(pctdict[pct][depvar_name], pctsig, threshold=peakthreshold)
    print(f'{pct*100:3.0f}% sampling peak locations: {peak_locs[0]:.2e}, {peak_locs[1]:.2e}')

plt.grid()
plt.xlabel('$\sigma$')
plt.ylabel('$\hat{\mathcal{D}}$')
plt.legend()
plt.xlim([np.min(pctsig), np.max(pctsig)])
plt.ylim([0,1.02])
plt.title('Detecting non-uniqueness through sensitivity to sampling')
plt.show()

```

As an example of comparing multiple representations of a manifold in the same dimensional space, we will use PCA. Below, two pca objects are created with different scalings. The first uses the default scaling `std` while the second uses the scaling `pareto`. The plots of the resulting manifolds are shown below for comparison to the original. The dimensions for the PCA manifolds are referred to as PC1 and PC2.

```python
# PCA using std scaling
pca_std = PCA(indepvars)
eta_std = pca_std.transform(indepvars)

plt.scatter(eta_std[:,0], eta_std[:,1], c=f, s=2, cmap='rainbow')
plt.colorbar()
plt.grid()
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.title('std scaling')
plt.show()

# PCA using pareto scaling
pca_pareto = PCA(indepvars,'pareto')
eta_pareto = pca_pareto.transform(indepvars)

plt.scatter(eta_pareto[:,0], eta_pareto[:,1], c=f, s=2, cmap='rainbow')
plt.colorbar()
plt.grid()
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.title('pareto scaling')
plt.show()

```

We call `compute_normalized_variance` in order to assess these manifolds in one and two dimensional space. Since PCA orders the PCs according the amount of variance explained, we will use PC1 for representing a one-dimensional manifold.

```python
pca1D_std = compute_normalized_variance(eta_std[:,:1], depvars, [depvar_name],bandwidth_values=bandwidth)
pca2D_std = compute_normalized_variance(eta_std,       depvars, [depvar_name],bandwidth_values=bandwidth)

pca1D_pareto = compute_normalized_variance(eta_pareto[:,:1], depvars, [depvar_name],bandwidth_values=bandwidth)
pca2D_pareto = compute_normalized_variance(eta_pareto,       depvars, [depvar_name],bandwidth_values=bandwidth)

```

We then go straight to plotting $\hat{\mathcal{D}}$ to see if new peaks are introduced compared to the original two-dimensional manifold, indicating new scales of variation. We again find that the one-dimensional projections are introducing a new scale. We could perform a similar analysis as shown above on the projection onto x to conclude that these new scales are also from non-uniqueness introduced in the projection. We therefore continue the analysis only considering two-dimensional parameterizations to figure out which one may be best in representing f.

```python
plt = plot_normalized_variance_derivative_comparison((pca1D_std, pca2D_std, pca1D_pareto, pca2D_pareto, orig2D),
                                                     ([],[],[],[],[]),
                                                     ('Blues', 'Reds', 'Purples', 'Oranges', 'Greens'))
plt.legend(['pca1D_std', 'pca2D_std', 'pca1D_pareto', 'pca2D_pareto', 'orig,2D'])
plt.show()

```

We compute the locations of the peaks in $\hat{\mathcal{D}}$ over $\sigma$ below.

```python
pca2D_std_derivative, pca2D_std_sigma  = normalized_variance_derivative(pca2D_std)
pca2D_pareto_derivative, pca2D_pareto_sigma  = normalized_variance_derivative(pca2D_pareto)
orig2D_derivative,  orig2D_sigma  = normalized_variance_derivative(orig2D)

pca2D_std_peak_locs, _ = find_local_maxima(pca2D_std_derivative[depvar_name], pca2D_std_sigma)
pca2D_pareto_peak_locs, _ = find_local_maxima(pca2D_pareto_derivative[depvar_name], pca2D_pareto_sigma)
orig2D_peak_locs, _ = find_local_maxima(orig2D_derivative[depvar_name], orig2D_sigma)

print('peak locations:')
print('orig2D',orig2D_peak_locs)
print('pca2D_std',pca2D_std_peak_locs)
print('pca2D_pareto',pca2D_pareto_peak_locs)

```

    peak locations:
    orig2D [0.66762295]
    pca2D_std [0.78185085]
    pca2D_pareto [0.67063695]

The results show that PCA with `std` scaling results in the largest feature size (largest $\sigma$) and is therefore the best for parameterizing f. This representation should better facilitate modeling of f as the features are more spread out.


# 01 - Introduction to seismic modelling

This notebook is the first in a series of tutorials highlighting various aspects of seismic inversion based on Devito operators. In this first example we aim to highlight the core ideas behind seismic modelling, where we create a numerical model that captures the processes involved in a seismic survey. This forward model will then form the basis for further tutorials on the implementation of inversion processes using Devito operators.

## Modelling workflow

The core process we are aiming to model is a seismic survey, which consists of two main components:

- **Source** - A source is positioned at a single or a few physical locations where artificial pressure is injected into the domain we want to model. In the case of land survey, it is usually dynamite blowing up at a given location, or a vibroseis (a vibrating engine generating continuous sound waves). For a marine survey, the source is an air gun sending a bubble of compressed air into the water that will expand and generate a seismic wave.
- **Receiver** - A set of microphones or hydrophones are used to measure the resulting wave and create a set of measurements called a *Shot Record*. These measurements are recorded at multiple locations, and usually at the surface of the domain or at the bottom of the ocean in some marine cases.

In order to create a numerical model of a seismic survey, we need to solve the wave equation and implement source and receiver interpolation to inject the source and record the seismic wave at sparse point locations in the grid.

## The acoustic seismic wave equation
The acoustic wave equation for the square slowness $m$, defined as $m=\frac{1}{c^2}$, where $c$ is the speed of sound in the given physical media, and a source $q$ is given by:

\begin{cases}
 &m \frac{d^2 u(x,t)}{dt^2} - \nabla^2 u(x,t) = q \ \text{in } \Omega \\
 &u(.,t=0) = 0 \\
 &\frac{d u(x,t)}{dt}|_{t=0} = 0
\end{cases}

with the zero initial conditions to guarantee unicity of the solution.
The boundary conditions are Dirichlet conditions:
\begin{equation}
 u(x,t)|_\delta\Omega = 0
\end{equation}

where $\delta\Omega$ is the surface of the boundary of the model $\Omega$.

# Finite domains

The last piece of the puzzle is the computational limitation. In the field, the seismic wave propagates in every direction to an "infinite" distance. However, solving the wave equation in a mathematically/discrete infinite domain is not feasible. In order to compensate, Absorbing Boundary Conditions (ABC) or Perfectly Matched Layers (PML) are required to mimic an infinite domain. These two methods allow to approximate an infinite media by damping and absorbing the waves at the limit of the domain to avoid reflections.

The simplest of these methods is the absorbing damping mask. The core idea is to extend the physical domain and to add a Sponge mask in this extension that will absorb the incident waves. The acoustic wave equation with this damping mask can be rewritten as:

\begin{cases}
 &m \frac{d^2 u(x,t)}{dt^2} - \nabla^2 u(x,t) + \eta \frac{d u(x,t)}{dt}=q  \ \text{in } \Omega \\
 &u(.,0) = 0 \\
 &\frac{d u(x,t)}{dt}|_{t=0} = 0
\end{cases}

where $\eta$ is the damping mask equal to $0$ inside the physical domain and increasing inside the sponge layer. Multiple choice of profile can be chosen for $\eta$ from linear to exponential.

# Seismic modelling with devito

We describe here a step by step setup of seismic modelling with Devito in a simple 2D case. We will create a physical model of our domain and define a single source and an according set of receivers to model for the forward model. But first, we initialize some basic utilities.

```python
#NBVAL_IGNORE_OUTPUT
# Adding ignore due to (probably an np notebook magic) bug
import numpy as np
%matplotlib inline
```

## Define the physical problem

The first step is to define the physical model:

- What are the physical dimensions of interest
- What is the velocity profile of this physical domain

We will create a simple velocity model here by hand for demonstration purposes. This model essentially consists of two layers, each with a different velocity: $1.5km/s$ in the top layer and $2.5km/s$ in the bottom layer. We will use this simple model a lot in the following tutorials, so we will rely on a utility function to create it again later.

```python
#NBVAL_IGNORE_OUTPUT
from examples.seismic import Model, plot_velocity

# Define a physical size
shape = (101, 101)  # Number of grid point (nx, nz)
spacing = (10., 10.)  # Grid spacing in m. The domain size is now 1km by 1km
origin = (0., 0.)  # What is the location of the top left corner. This is necessary to define
# the absolute location of the source and receivers

# Define a velocity profile. The velocity is in km/s
v = np.empty(shape, dtype=np.float32)
v[:, :51] = 1.5
v[:, 51:] = 2.5

# With the velocity and model size defined, we can create the seismic model that
# encapsulates this properties. We also define the size of the absorbing layer as 10 grid points
model = Model(vp=v, origin=origin, shape=shape, spacing=spacing,
              space_order=2, nbl=10, bcs="damp")

plot_velocity(model)
```

# Acquisition geometry

To fully define our problem setup we also need to define the source that injects the wave to model and the set of receiver locations at which to sample the wavefield. The source time signature will be modelled using a Ricker wavelet defined as

\begin{equation}
  q(t) = (1-2\pi^2 f_0^2 (t - \frac{1}{f_0})^2 )e^{- \pi^2 f_0^2 (t - \frac{1}{f_0})}
\end{equation}

To fully define the source signature we first need to define the time duration for our model and the timestep size, which is dictated by the CFL condition and our grid spacing. Luckily, our `Model` utility provides us with the critical timestep size, so we can fully discretize our model time axis as an array:

```python
from examples.seismic import TimeAxis

t0 = 0.  # Simulation starts a t=0
tn = 1000.  # Simulation last 1 second (1000 ms)
dt = model.critical_dt  # Time step from model grid spacing

time_range = TimeAxis(start=t0, stop=tn, step=dt)
```

The source is positioned at a $20m$ depth and at the middle of the $x$ axis ($x_{src}=500m$), with a peak wavelet frequency of $10Hz$.

```python
#NBVAL_IGNORE_OUTPUT
from examples.seismic import RickerSource

f0 = 0.010  # Source peak frequency is 10Hz (0.010 kHz)
src = RickerSource(name='src', grid=model.grid, f0=f0,
                   npoint=1, time_range=time_range)

# First, position source centrally in all dimensions, then set depth
src.coordinates.data[0, :] = np.array(model.domain_size) * .5
src.coordinates.data[0, -1] = 20.  # Depth is 20m

# We can plot the time signature to see the wavelet
src.show()
```

Similarly to our source object, we can now define our receiver geometry as a symbol of type `Receiver`. It is worth noting here that both utility classes, `RickerSource` and `Receiver` are thin wrappers around the Devito's `SparseTimeFunction` type, which encapsulates sparse point data and allows us to inject and interpolate values into and out of the computational grid. As we have already seen, both types provide a `.coordinates` property to define the position within the domain of all points encapsulated by that symbol.

In this example we will position receivers at the same depth as the source, every $10m$ along the x axis. The `rec.data` property will be initialized, but left empty, as we will compute the receiver readings during the simulation.

```python
#NBVAL_IGNORE_OUTPUT
from examples.seismic import Receiver

# Create symbol for 101 receivers
rec = Receiver(name='rec', grid=model.grid, npoint=101, time_range=time_range)

# Prescribe even spacing for receivers along the x-axis
rec.coordinates.data[:, 0] = np.linspace(0, model.domain_size[0], num=101)
rec.coordinates.data[:, 1] = 20.  # Depth is 20m

# We can now show the source and receivers within our domain:
# Red dot: Source location
# Green dots: Receiver locations (every 4th point)
plot_velocity(model, source=src.coordinates.data,
              receiver=rec.coordinates.data[::4, :])
```

# Finite-difference discretization

Devito is a finite-difference DSL that solves the discretized wave-equation on a Cartesian grid. The finite-difference approximation is derived from Taylor expansions of the continuous field after removing the error term.

## Time discretization

We only consider the second order time discretization for now. From the Taylor expansion, the second order discrete approximation of the second order time derivative is:
\begin{equation}
\begin{aligned}
 \frac{d^2 u(x,t)}{dt^2} = \frac{\mathbf{u}(\mathbf{x},\mathbf{t+\Delta t}) - 2 \mathbf{u}(\mathbf{x},\mathbf{t}) + \mathbf{u}(\mathbf{x},\mathbf{t-\Delta t})}{\mathbf{\Delta t}^2} + O(\mathbf{\Delta t}^2).
\end{aligned}
\end{equation}

where $\mathbf{u}$ is the discrete wavefield, $\mathbf{\Delta t}$ is the discrete
time-step (distance between two consecutive discrete time points) and $O(\mathbf{\Delta
  t}^2)$ is the discretization error term. The discretized approximation of the
second order time derivative is then given by dropping the error term. This derivative is represented in Devito by `u.dt2` where u is a `TimeFunction` object.

## Spatial discretization

We define the discrete Laplacian as the sum of the second order spatial
derivatives in the three dimensions:
\begin{equation}
\begin{aligned}
\Delta \mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t})= \sum_{j=1}^{j=\frac{k}{2}} \Bigg[\alpha_j \Bigg(&
\mathbf{u}(\mathbf{x+jdx},\mathbf{y},\mathbf{z},\mathbf{t})+\mathbf{u}(\mathbf{x-jdx},\mathbf{y},\mathbf{z},\mathbf{t}) + \\
&\mathbf{u}(\mathbf{x},\mathbf{y+jdy},\mathbf{z},\mathbf{t})+\mathbf{u}(\mathbf{x},\mathbf{y-jdy},\mathbf{z}\mathbf{t}) + \\
&\mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z+jdz},\mathbf{t})+\mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z-jdz},\mathbf{t})\Bigg) \Bigg] + \\
&3\alpha_0 \mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}).
\end{aligned}
\end{equation}

This derivative is represented in Devito by `u.laplace` where u is a `TimeFunction` object.

## Wave equation

With the space and time discretization defined, we can fully discretize the wave-equation with the combination of time and space discretizations and obtain the following second order in time and $k^{th}$ order in space discrete stencil to update one grid point at position $\mathbf{x}, \mathbf{y},\mathbf{z}$ at time $\mathbf{t}$, i.e.
\begin{equation}
\begin{aligned}
\mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t+\Delta t}) = &2\mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}) - \mathbf{u}(\mathbf{x},\mathbf{y}, \mathbf{z},\mathbf{t-\Delta t}) +\\
& \frac{\mathbf{\Delta t}^2}{\mathbf{m(\mathbf{x},\mathbf{y},\mathbf{z})}} \Big(\Delta \mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}) + \mathbf{q}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}) \Big).
\end{aligned}
\end{equation}

```python
# In order to represent the wavefield u and the square slowness we need symbolic objects
# corresponding to time-space-varying field (u, TimeFunction) and
# space-varying field (m, Function)
from devito import TimeFunction

# Define the wavefield with the size of the model and the time dimension
u = TimeFunction(name="u", grid=model.grid, time_order=2, space_order=2)

# We can now write the PDE
pde = model.m * u.dt2 - u.laplace + model.damp * u.dt

# The PDE representation is as on paper
pde
```

$\displaystyle \operatorname{damp}{\left(x,y \right)} \frac{\partial}{\partial t} u{\left(t,x,y \right)} - \frac{\partial^{2}}{\partial x^{2}} u{\left(t,x,y \right)} - \frac{\partial^{2}}{\partial y^{2}} u{\left(t,x,y \right)} + \frac{\frac{\partial^{2}}{\partial t^{2}} u{\left(t,x,y \right)}}{\operatorname{vp}^{2}{\left(x,y \right)}}$

```python
# This discrete PDE can be solved in a time-marching way updating u(t+dt) from the previous time step
# Devito as a shortcut for u(t+dt) which is u.forward. We can then rewrite the PDE as
# a time marching updating equation known as a stencil using customized SymPy functions
from devito import Eq, solve

stencil = Eq(u.forward, solve(pde, u.forward))
stencil
```

$\displaystyle u{\left(t + dt,x,y \right)} = \frac{- \frac{- \frac{2.0 u{\left(t,x,y \right)}}{dt^{2}} + \frac{u{\left(t - dt,x,y \right)}}{dt^{2}}}{\operatorname{vp}^{2}{\left(x,y \right)}} + \frac{\partial^{2}}{\partial x^{2}} u{\left(t,x,y \right)} + \frac{\partial^{2}}{\partial y^{2}} u{\left(t,x,y \right)} + \frac{\operatorname{damp}{\left(x,y \right)} u{\left(t,x,y \right)}}{dt}}{\frac{\operatorname{damp}{\left(x,y \right)}}{dt} + \frac{1}{dt^{2} \operatorname{vp}^{2}{\left(x,y \right)}}}$

# Source injection and receiver interpolation

With a numerical scheme to solve the homogenous wave equation, we need to add the source to introduce seismic waves and to implement the measurement operator, and interpolation operator. This operation is linked to the discrete scheme and needs to be done at the proper time step. The semi-discretized in time wave equation with a source reads:

\begin{equation}
\begin{aligned}
\mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t+\Delta t}) = &2\mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}) - \mathbf{u}(\mathbf{x},\mathbf{y}, \mathbf{z},\mathbf{t-\Delta t}) +\\
& \frac{\mathbf{\Delta t}^2}{\mathbf{m(\mathbf{x},\mathbf{y},\mathbf{z})}} \Big(\Delta \mathbf{u}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}) + \mathbf{q}(\mathbf{x},\mathbf{y},\mathbf{z},\mathbf{t}) \Big).
\end{aligned}
\end{equation}

It shows that in order to update $\mathbf{u}$ at time $\mathbf{t+\Delta t}$ we have to inject the value of the source term $\mathbf{q}$ of time $\mathbf{t}$. In Devito, it corresponds the update of $u$ at index $t+1$ (t = time implicitly) with the source of time $t$.
On the receiver side, the problem is either as it only requires to record the data at the given time step $t$ for the receiver at time $time=t$.

```python
# Finally we define the source injection and receiver read function to generate the corresponding code
src_term = src.inject(field=u.forward, expr=src * dt**2 / model.m)

# Create interpolation expression for receivers
rec_term = rec.interpolate(expr=u.forward)
```

# Devito operator and solve
After constructing all the necessary expressions for updating the wavefield, injecting the source term and interpolating onto the receiver points, we can now create the Devito operator that will generate the C code at runtime. When creating the operator, Devito's two optimization engines will log which performance optimizations have been performed:
* **DSE:** The Devito Symbolics Engine will attempt to reduce the number of operations required by the kernel.
* **DLE:** The Devito Loop Engine will perform various loop-level optimizations to improve runtime performance.

**Note**: The argument `subs=model.spacing_map` causes the operator to substitute values for our current grid spacing into the expressions before code generation. This reduces the number of floating point operations executed by the kernel by pre-evaluating certain coefficients.

```python
#NBVAL_IGNORE_OUTPUT
from devito import Operator

op = Operator([stencil] + src_term + rec_term, subs=model.spacing_map)
```

Now we can execute the create operator for a number of timesteps. We specify the number of timesteps to compute with the keyword `time` and the timestep size with `dt`.

```python
#NBVAL_IGNORE_OUTPUT
op(time=time_range.num-1, dt=model.critical_dt)
```

    Operator `Kernel` ran in 0.01 s

    PerformanceSummary([(PerfKey(name='section0', rank=None),
                         PerfEntry(time=0.004936999999999981, gflopss=0.0, gpointss=0.0, oi=0.0, ops=0, itershapes=[])),
                        (PerfKey(name='section1', rank=None),
                         PerfEntry(time=2.600000000000001e-05, gflopss=0.0, gpointss=0.0, oi=0.0, ops=0, itershapes=[])),
                        (PerfKey(name='section2', rank=None),
                         PerfEntry(time=0.0007560000000000057, gflopss=0.0, gpointss=0.0, oi=0.0, ops=0, itershapes=[]))])

After running our operator kernel, the data associated with the receiver symbol `rec.data` has now been populated due to the interpolation expression we inserted into the operator. This allows us the visualize the shot record:

```python
#NBVAL_IGNORE_OUTPUT
from examples.seismic import plot_shotrecord

plot_shotrecord(rec.data, model, t0, tn)
```

```python
assert np.isclose(np.linalg.norm(rec.data), 370, rtol=1)
```