# General Relativity

```python
import numpy as np
import matplotlib.pyplot as plt
from functools import partial
import ipywidgets

def gamma(b):
    return 1/np.sqrt(1-b**2)
```

### Hw1.3

How does an isotropically emitting gamma source look like from the point of view of a moving observer?

```python
@np.vectorize
def theta(theta_prime,beta):
    """
    Returns the angle of the radiation,
    emitted by the moving observer O' at an angle theta_prime,
    as seen by the stationary observer O,
    if the relative speed between O and O' is beta*c.
    """

    s = np.sin(theta_prime)/(gamma(beta)*(1 + np.cos(theta_prime)*beta))
    c = (np.cos(theta_prime) + beta)/(1 + np.cos(theta_prime)*beta)
    if s >= 0:
        return np.arccos(c)
    else:
        return 2*np.pi - np.arccos(c)
```

```python
# varying beta

theta_prime = np.pi/2

betas = np.arange(0,1,0.01)

thetas = [theta(theta_prime=theta_prime,beta=b) for b in betas]
thetas = np.array(thetas)

plt.figure()
plt.plot(betas,thetas)
plt.xlabel('beta')
plt.ylabel('theta')
plt.show()

```

```python
# varying theta_prime

beta = 0.9

theta_primes = np.arange(0,2*np.pi,2*np.pi/100)

thetas = [theta(theta_prime=t,beta=beta) for t in theta_primes]
thetas = np.array(thetas)

plt.figure()
plt.plot(theta_primes*180/np.pi,thetas*180/np.pi)
plt.xlabel('theta\'')
plt.ylabel('theta')
plt.show()
```

```python
# multiple rays

n_rays = 16

theta_primes = np.arange(0,2*np.pi,2*np.pi/n_rays)

def plot_rays(beta):

    thetas = [theta(theta_prime=t,beta=beta) for t in theta_primes]
    thetas = np.array(thetas)

    plt.figure()
    for t in thetas:
        plt.plot([0,np.cos(t)],[0,np.sin(t)])
    plt.show()

ipywidgets.interact(plot_rays, beta = (0,0.99,0.01))
```

    <function __main__.plot_rays>

```python
# Intensity

n_rays = 16
amp_factor = 1000

theta_primes = np.arange(0,np.pi,np.pi/(amp_factor*n_rays))
theta_scan = np.arange(0,np.pi+np.pi/n_rays,np.pi/n_rays)

def plot_intensity(beta):

    thetas = [theta(theta_prime=t,beta=beta) for t in theta_primes]
    thetas = np.array(thetas)

    intensity = np.zeros_like(theta_scan)

    j = 0
    last_i = 0
    for i,t in enumerate(thetas):
        if j >= len(intensity):
                print('error')
                break
        if t > theta_scan[j]:
            #print(i)
            intensity[j] = i - last_i
            last_i = i
            j += 1
    intensity[-1] = n_rays*amp_factor - last_i
    2
    intensity /= 1.*n_rays*amp_factor

    plt.figure()
    plt.plot(theta_scan[1:]*180/np.pi,intensity[1:])
    plt.xlabel('theta')
    plt.ylabel('normalized intensity')
    plt.title('angular spectrum')
    plt.show()

#plot_intensity(0.3)

ipywidgets.interact(plot_intensity, beta = (0,0.99,0.01))
```

    <function __main__.plot_intensity>

```python
# accounting for 3-dimensionality

n_rays = 16
amp_factor = 1000

theta_primes = np.arange(0,np.pi,np.pi/(amp_factor*n_rays))
theta_scan = np.arange(0,np.pi+np.pi/n_rays,np.pi/n_rays)

def plot_intensity(beta):

    thetas = [theta(theta_prime=t,beta=beta) for t in theta_primes]
    thetas = np.array(thetas)

    intensity = np.zeros_like(theta_scan)

    j = 0
    for i,t in enumerate(thetas):
        if j >= len(intensity):
                print('error')
                break
        intensity[j] += (0.00001 + np.sin(theta_primes[i]))/(0.0001 + np.sin(theta_scan[j]))
        if t > theta_scan[j]:
            #print(i)
            j += 1

    s = sum(intensity)
    print(intensity,s)
    intensity /= 1.*s

    plt.figure()
    plt.semilogy(theta_scan[1:]*180/np.pi,intensity[1:])
    plt.xlabel('theta')
    plt.ylabel('normalized intensity')
    plt.title('angular spectrum')
    plt.show()

# plot_intensity(0.)

ipywidgets.interact(plot_intensity, beta = (0,0.99,0.01))
```

    <function __main__.plot_intensity>

```python
# just to clarify how (arc)cos and (arc)sin work

theta_primes = np.arange(0,2*np.pi,2*np.pi/100)

plt.figure()
plt.plot(theta_primes,np.arcsin(np.sin(theta_primes)), label = 'sin')
plt.plot(theta_primes,np.arccos(np.cos(theta_primes)), label = 'cos')
plt.legend()
plt.show()

```

```python
# Analytical solution to the intensity problem:
# assume in its own frame the source emits energy isotropically:
# what is the angular distribution seen by the observer?
# It will be cylindrically symmetric of course: what is the distribution wrt theta?

import sympy as sp
sp.init_printing()

v = sp.symbols('v', real=True)
gamma_sp = 1 / sp.sqrt(1-v**2)

theta_prime = sp.symbols('theta_prime', real=True)

# angle in the observer frame O
theta_orig = sp.atan(1/gamma_sp/(1/sp.tan(theta_prime) + v/sp.sin(theta_prime)))

# distribution means the derivative of theta in O wrt theta in O'
distribution = sp.diff(theta_orig, theta_prime)
# taking account of the size of the spherical portion.
# the absolute value is needed because of some issues with the arccosine and arccsine
# which are difficult to deal with in sympy
distribution_3d = sp.Abs(distribution * sp.sin(theta_orig) / sp.sin(theta_prime))
```

```python
def plot_distribution(relative_velocity):

    # take the generic distributions and insert the actual velocity
    new_distribution = distribution.subs(v, relative_velocity)
    new_distribution_3d = distribution_3d.subs(v, relative_velocity)

    x = sp.symbols('x')

    # we make functions from the sympy relations, the inverse is
    # inserted because we want to plot d (theta_prime) / d (theta)
    f = sp.lambdify(x, 1/new_distribution.subs(theta_prime, x), 'numpy')
    f_3d = sp.lambdify(x, 1/new_distribution_3d.subs(theta_prime, x), 'numpy')

    theta_primes = np.linspace(1e-10, np.pi-1e-10)
    thetas = theta(theta_primes, relative_velocity)

    plt.ylabel("dtheta_prime/dtheta")
    plt.xlabel("theta")
    plt.semilogy(thetas, f(theta_primes), label="2d")
    plt.semilogy(thetas, f_3d(theta_primes), label="3d")
    plt.legend()
    plt.suptitle(f"relative velocity = {relative_velocity:.4f}")
    plt.show()

def log_plot(log_velocity):
    plot_distribution(1-10**(-log_velocity))

# ipywidgets.interact(plot_distribution, relative_velocity=(0,0.99,0.01), continuous_update=False)
ipywidgets.interact(log_plot, log_velocity=(0,2.5,0.05), continuous_update=False)
```

    <function __main__.log_plot>

### Ex 7.1.4

```python
GM = 1

@np.vectorize
def psi(r):
    return np.arctan(np.sqrt(1/(r**3/(27*GM**2*(r-2*GM)) - 1)))

rs = np.arange(2*GM+0.04,3*GM-0.04,0.01)
psis = psi(rs)

plt.figure()
plt.plot(rs,psis)
plt.xlabel('r/GM')
plt.ylabel('psi')
plt.show()

```

```python

```


```python
%matplotlib inline
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
import seaborn as sns
```

Numerical Evaluation of Integrals
====

Integration problems are common in statistics whenever we are dealing with continuous distributions. For example the expectation of a function is an integration problem

$$
E[f(x)] = \int{f(x) \, p(x) \, dx}
$$

In Bayesian statistics, we need to solve the integration problem for the marginal likelihood or evidence

$$
p(X \mid \alpha) = \int{p(X \mid \theta) \, p(\theta \mid \alpha) d\theta}
$$

where $\alpha$ is a hyperparameter and $p(X \mid \alpha)$ appears in the denominator of Bayes theorem

$$
p(\theta | X) = \frac{p(X \mid \theta) \, p(\theta \mid \alpha)}{p(X \mid \alpha)}
$$

In general, there is no closed form solution to these integrals, and we have to approximate them numerically. The first step is to check if there is some **reparameterization** that will simplify the problem. Then, the general approaches to solving integration problems are

1. Numerical quadrature
2. Importance sampling, adaptive importance sampling and variance reduction techniques (Monte Carlo swindles)
3. Markov Chain Monte Carlo
4. Asymptotic approximations (Laplace method and its modern version in variational inference)

This lecture will review the concepts for quadrature and Monte Carlo integration.

## Quadrature

You may recall from Calculus that integrals can be numerically evaluated using quadrature methods such as Trapezoid and Simpson's's rules. This is easy to do in Python, but has the drawback of the complexity growing as $O(n^d)$ where $d$ is the dimensionality of the data, and hence infeasible once $d$ grows beyond a modest number.

### Integrating functions

```python
from scipy.integrate import quad
```

```python
def f(x):
    return x * np.cos(71*x) + np.sin(13*x)
```

```python
x = np.linspace(0, 1, 100)
plt.plot(x, f(x))
pass
```

#### Exact solution

```python
from sympy import sin, cos, symbols, integrate

x = symbols('x')
integrate(x * cos(71*x) + sin(13*x), (x, 0,1)).evalf(6)
```

    0.0202549

#### Using quadrature

```python
y, err = quad(f, 0, 1.0)
y
```

    0.02025493910239419

#### Multiple integration

Following the `scipy.integrate` [documentation](http://docs.scipy.org/doc/scipy/reference/tutorial/integrate.html), we integrate

$$
I=\int_{y=0}^{1/2}\int_{x=0}^{1-2y} x y \, dx\, dy
$$

```python
x, y = symbols('x y')
integrate(x*y, (x, 0, 1-2*y), (y, 0, 0.5))
```

    0.0104166666666667

```python
from scipy.integrate import nquad

def f(x, y):
    return x*y

def bounds_y():
    return [0, 0.5]

def bounds_x(y):
    return [0, 1-2*y]

y, err = nquad(f, [bounds_x, bounds_y])
y
```

    0.010416666666666668

Monte Carlo integration
----

The basic idea of Monte Carlo integration is very simple and only requires elementary statistics. Suppose we want to find the value of
$$
I = \int_a^b f(x) dx
$$
in some region with volume $V$. Monte Carlo integration estimates this integral by estimating the fraction of random points that fall below $f(x)$ multiplied by $V$.

In a statistical context, we use Monte Carlo integration to estimate the expectation
$$
E[g(X)] = \int_X g(x) p(x) dx
$$

with

$$
\bar{g_n} = \frac{1}{n} \sum_{i=1}^n g(x_i)
$$
where $x_i \sim p$ is a draw from the density $p$.

We can estimate the Monte Carlo variance of the approximation as
$$
v_n = \frac{1}{n^2} \sum_{o=1}^n (g(x_i) - \bar{g_n})^2)
$$

Also, from the Central Limit Theorem,

$$
\frac{\bar{g_n} - E[g(X)]}{\sqrt{v_n}} \sim \mathcal{N}(0, 1)
$$

The convergence of Monte Carlo integration is $\mathcal{0}(n^{1/2})$ and independent of the dimensionality. Hence Monte Carlo integration generally beats numerical integration for moderate- and high-dimensional integration since numerical integration (quadrature) converges as $\mathcal{0}(n^{d})$. Even for low dimensional problems, Monte Carlo integration may have an advantage when the volume to be integrated is concentrated in a very small region and we can use information from the distribution to draw samples more often in the region of importance.

An elementary, readable description of Monte Carlo integration and variance reduction techniques can be found [here](https://www.cs.dartmouth.edu/~wjarosz/publications/dissertation/appendixA.pdf).

### Intuition behind Monte Carlo integration

We want to find some integral

$$I = \int{f(x)} \, dx$$

Consider the expectation of a function $g(x)$ with respect to some distribution $p(x)$. By definition, we have

$$
E[g(x)] = \int{g(x) \, p(x) \, dx}
$$

If we choose $g(x) = f(x)/p(x)$, then we have

$$
\begin{align}
E[g(x)] &= \int{\frac{f(x}{p(x)} \, p(x) \, dx} \\
&= \int{f(x) dx} \\
&= I
\end{align}
$$

By the law of large numbers, the average converges on the expectation, so we have

$$
I \approx \bar{g_n} = \frac{1}{n} \sum_{i=1}^n g(x_i)
$$

If $f(x)$ is a proper integral (i.e. bounded), and $p(x)$ is the uniform distribution, then $g(x) = f(x)$ and this is known as ordinary Monte Carlo. If the integral of $f(x)$ is improper, then we need to use another distribution with the same support as $f(x)$.

### Intuition for error rate

We will just work this out for a proper integral $f(x)$ defined in the unit cube and bounded by $|f(x)| \le 1$. Draw a random uniform vector $x$ in the unit cube. Then

$$
\begin{align}
E[f(x_i)] &= \int{f(x) p(x) dx} = I \\
\text{Var}[f(x_i)] &= \int{(f(x_i) - I )^2 p(x) \, dx} \\
&= \int{f(x)^2 \, p(x) \, dx} - 2I \int(f(x) \, p(x) \, dx + I^2 \int{p(x) \, dx} \\
& \le \int{f(x)^2 \, p(x) \, dx}  + I^2 \\
& \le \int{f(x)^2 \, p(x) \, dx} \\
& \le \int{p(x) \, dx} = 1
\end{align}
$$

Now consider summing over many such IID draws $S_n = f(x_1) + f(x_2) + \cdots + f(x_n)$. We have

$$
\begin{align}
E[S_n] &= nI \\
\text{Var}[S_n] & \le n
\end{align}
$$

and as expected, we see that $I \approx S_n/n$. From Chebyshev's inequality,

$$
\begin{align}
P \left( \left| \frac{s_n}{n} - I \right| \ge \epsilon \right)  &=
P \left( \left| s_n - nI \right| \ge n \epsilon \right) & \le \frac{\text{Var}[s_n]}{n^2 \epsilon^2} & \le
\frac{1}{n \epsilon^2} = \delta
\end{align}
$$

Suppose we want 1% accuracy and 99% confidence - i.e. set $\epsilon = \delta = 0.01$. The above inequality tells us that we can achieve this with just $n = 1/(\delta \epsilon^2) = 1,000,000$ samples, regardless of the data dimensionality.

### Example

We want to estimate the following integral $\int_0^1 e^x dx$. The minimum value of the function is 1 at $x=0$ and $e$ at $x=1$.

```python
x = np.linspace(0, 1, 100)
plt.plot(x, np.exp(x))
plt.xlim([0,1])
plt.ylim([0, np.e])
pass
```

#### Analytic solution

```python
from sympy import symbols, integrate, exp

x = symbols('x')
expr = integrate(exp(x), (x,0,1))
expr.evalf()
```

    1.71828182845905

#### Using quadrature

```python
from scipy import integrate

y, err = integrate.quad(exp, 0, 1)
y
```

    1.7182818284590453

#### Monte Carlo integration

```python
for n in 10**np.array([1,2,3,4,5,6,7,8]):
    x = np.random.uniform(0, 1, n)
    sol = np.mean(np.exp(x))
    print('%10d %.6f' % (n, sol))
```

            10 1.657995
           100 1.736252
          1000 1.712426
         10000 1.718823
        100000 1.718524
       1000000 1.718875
      10000000 1.718498
     100000000 1.718227

### Monitoring variance in Monte Carlo integration

We are often interested in knowing how many iterations it takes for Monte Carlo integration to "converge". To do this, we would like some estimate of the variance, and it is useful to inspect such plots. One simple way to get confidence intervals for the plot of Monte Carlo estimate against number of iterations is simply to do many such simulations.

For the example, we will try to estimate the function (again)

$$
f(x) = x \cos 71 x + \sin 13x, \ \  0 \le x \le 1
$$

```python
def f(x):
    return x * np.cos(71*x) + np.sin(13*x)
```

```python
x = np.linspace(0, 1, 100)
plt.plot(x, f(x))
pass
```

#### Single MC integration estimate

```python
n = 100
x = f(np.random.random(n))
y = 1.0/n * np.sum(x)
y
```

    -0.13559989390095498

#### Using multiple independent sequences to monitor convergence

We vary the sample size from 1 to 100 and calculate the value of $y = \sum{x}/n$ for 1000 replicates. We then plot the 2.5th and 97.5th percentile of the 1000 values of $y$ to see how the variation in $y$ changes with sample size. The blue lines indicate the 2.5th and 97.5th percentiles, and the red line a sample path.

```python
n = 100
reps = 1000

x = f(np.random.random((n, reps)))
y = 1/np.arange(1, n+1)[:, None] * np.cumsum(x, axis=0)
upper, lower = np.percentile(y, [2.5, 97.5], axis=1)
```

```python
plt.plot(np.arange(1, n+1), y, c='grey', alpha=0.02)
plt.plot(np.arange(1, n+1), y[:, 0], c='red', linewidth=1);
plt.plot(np.arange(1, n+1), upper, 'b', np.arange(1, n+1), lower, 'b')
pass
```

#### Using bootstrap to monitor convergence

If it is too expensive to do 1000 replicates, we can use a bootstrap instead.

```python
xb = np.random.choice(x[:,0], (n, reps), replace=True)
yb = 1/np.arange(1, n+1)[:, None] * np.cumsum(xb, axis=0)
upper, lower = np.percentile(yb, [2.5, 97.5], axis=1)
```

```python
plt.plot(np.arange(1, n+1)[:, None], yb, c='grey', alpha=0.02)
plt.plot(np.arange(1, n+1), yb[:, 0], c='red', linewidth=1)
plt.plot(np.arange(1, n+1), upper, 'b', np.arange(1, n+1), lower, 'b')
pass
```

Variance Reduction
----

With independent samples, the variance of the Monte Carlo estimate is

$$
\begin{align}
\text{Var}[\bar{g_n}] &= \text{Var} \left[ \frac{1}{N}\sum_{i=1}^{N} \frac{f(x_i)}{p(x_i)} \right] \\
&= \frac{1}{N^2} \sum_{i=1}^{N}  \text{Var} \left[ \frac{f(x_i)}{p(x_i)} \right] \\
&= \frac{1}{N^2} \sum_{i=1}^{N} \text{Var}[Y_i] \\
&= \frac{1}{N} \text{Var}[Y_i]
\end{align}
$$

where $Y_i = f(x_i)/p(x_i)$. The objective of Monte Carlo swindles is to make $\text{Var}[\bar{g_n}]$ as small as possible for the same number of samples.

### Change of variables

The Cauchy distribution is given by
$$
f(x) = \frac{1}{\pi (1 + x^2)}, \ \ -\infty \lt x \lt \infty
$$

Suppose we want to integrate the tail probability $P(X > 3)$ using Monte Carlo. One way to do this is to draw many samples form a Cauchy distribution, and count how many of them are greater than 3, but this is extremely inefficient.

#### Only 10% of samples will be used

```python
import scipy.stats as stats

h_true = 1 - stats.cauchy().cdf(3)
h_true
```

    0.10241638234956674

```python
n = 100

x = stats.cauchy().rvs(n)
h_mc = 1.0/n * np.sum(x > 3)
h_mc, np.abs(h_mc - h_true)/h_true
```

    (0.14000000000000001, 0.36696880702301304)

#### A change of variables lets us use 100% of draws

We are trying to estimate the quantity

$$
\int_3^\infty \frac{1}{\pi (1 + x^2)} dx
$$

Using the substitution $y = 3/x$ (and a little algebra), we get

$$
\int_0^1 \frac{3}{\pi(9 + y^2)} dy
$$

Hence, a much more efficient MC estimator is

$$
\frac{1}{n} \sum_{i=1}^n \frac{3}{\pi(9 + y_i^2)}
$$

where $y_i \sim \mathcal{U}(0, 1)$.

```python
y = stats.uniform().rvs(n)
h_cv = 1.0/n * np.sum(3.0/(np.pi * (9 + y**2)))
h_cv, np.abs(h_cv - h_true)/h_true
```

    (0.10213456867129996, 0.0027516464827364996)

### Monte Carlo swindles

Apart from change of variables, there are several general techniques for variance reduction, sometimes known as Monte Carlo swindles since these methods improve the accuracy and convergence rate of Monte Carlo integration without increasing the number of Monte Carlo samples. Some Monte Carlo swindles are:

- importance sampling
- stratified sampling
- control variates
- antithetic variates
- conditioning swindles including Rao-Blackwellization and independent variance decomposition

Most of these techniques are not particularly computational in nature, so we will not cover them in the course. I expect you will learn them elsewhere. We will illustrate importance sampling and antithetic variables here as examples.

### Antithetic variables

The idea behind antithetic variables is to choose two sets of random numbers that are negatively correlated, then take their average, so that the total variance of the estimator is smaller than it would be with two sets of IID random variables.

```python
def f(x):
    return x * np.cos(71*x) + np.sin(13*x)
```

```python
from sympy import sin, cos, symbols, integrate

x = symbols('x')
sol = integrate(x * cos(71*x) + sin(13*x), (x, 0,1)).evalf(16)
sol
```

    0.02025493910239406

#### Vanilla Monte Carlo

```python
n = 10000
u = np.random.random(n)
x = f(u)
y = 1.0/n * np.sum(x)
y, abs(y-sol)/sol
```

    (0.019429826681871879, 0.04073635651783584)

#### Antithetic variables use first half of `u`  supplemented with `1-u`

This works because the random draws are now negatively correlated, and hence the sum of the variances will be less than in the IID case, while the expectation is unchanged.

```python
u = np.r_[u[:n//2], 1-u[:n//2]]
x = f(u)
y = 1.0/n * np.sum(x)
y, abs(y-sol)/sol
```

    (0.021069877090343692, 0.04023403792180801)

### Importance sampling

Ordinary Monte Carlo sampling evaluates

$$
E[g(X)] = \int_X g(x)\, p(x) \, dx
$$

Using another distribution $h(x)$ - the so-called "importance function", we can rewrite the above expression as an expectation with respect to $h$

$$
E_p[g(x)] \ = \  \int_X g(x) \frac{p(x)}{h(x)} h(x) dx \ = \ E_h\left[ \frac{g(X) p(X)}{h(X)} \right]
$$

giving us the new estimator

$$
\bar{g_n} = \frac{1}{n} \sum_{i=1}^n \frac{p(x_i)}{h(x_i)} g(x_i)
$$

where $x_i \sim g$ is a draw from the density $h$. This is helpful if the distribution $h$ has a similar shape as the function $f(x)$ that we are integrating over, since we will draw more samples from places where the integrand makes a larger or more "important" contribution. This is very dependent on a good choice for the importance function $h$. Two simple choices for $h$ are scaling

$$
h(x) = \frac{1}{a} p(x/a)
$$

and translation

$$
h(x) = p*(x - a)
$$

In these cases, the parameter $a$ is typically chosen using some adaptive algorithm, giving rise to adaptive importance sampling. Alternatively, a different distribution can be chosen as shown in the example below.

#### Example

Suppose we want to estimate the tail probability of $\mathcal{N}(0, 1)$ for $P(X > 5)$. Regular MC integration using samples from $\mathcal{N}(0, 1)$ is hopeless since nearly all samples will be rejected. However, we can use the exponential density truncated at 5 as the importance function and use importance sampling.

```python
x = np.linspace(4, 10, 100)
plt.plot(x, stats.expon(5).pdf(x))
plt.plot(x, stats.norm().pdf(x))
pass
```

#### Expected answer

We expect about 3 draws out of 10,000,000 from $\mathcal{N}(0, 1)$ to have a value greater than 5. Hence simply sampling from $\mathcal{N}(0, 1)$ is hopelessly inefficient for Monte Carlo integration.

```python
%precision 10
```

    '%.10f'

```python
h_true =1 - stats.norm().cdf(5)
h_true
```

    0.0000002867

#### Using direct Monte Carlo integration

```python
n = 10000
y = stats.norm().rvs(n)
h_mc = 1.0/n * np.sum(y > 5)
# estimate and relative error
h_mc, np.abs(h_mc - h_true)/h_true
```

    (0.0000000000, 1.0000000000)

#### Using importance sampling

```python
n = 10000
y = stats.expon(loc=5).rvs(n)
h_is = 1.0/n * np.sum(stats.norm().pdf(y)/stats.expon(loc=5).pdf(y))
# estimate and relative error
h_is, np.abs(h_is- h_true)/h_true
```

    (0.0000002944, 0.0270524683)

Quasi-random numbers
----

Recall that the convergence of Monte Carlo integration is $\mathcal{0}(n^{1/2})$. One issue with simple Monte Carlo is that randomly chosen points tend to be clumped. Clumping reduces accuracy since nearby points provide little additional information about the function begin estimated. One way to address this is to split the space into multiple integration regions, then sum them up. This is known as **stratified sampling**. Another alternative is to use quasi-random numbers which fill space more efficiently than random sequences

It turns out that if we use quasi-random or low discrepancy sequences, we can get convergence approaching $\mathcal{0}(1/n)$. There are several such generators, but their use in statistical settings is limited to cases where we are integrating with respect to uniform distributions. The regularity can also give rise to errors when estimating integrals of periodic functions. However, these quasi-Monte Carlo methods are used in computational finance models.

Run
```
! pip install ghalton
```
if `ghalton` is not installed.

```python
import ghalton

gen = ghalton.Halton(2)
```

```python
plt.figure(figsize=(10,5))
plt.subplot(121)
xs = np.random.random((100,2))
plt.scatter(xs[:, 0], xs[:,1])
plt.axis([-0.05, 1.05, -0.05, 1.05])
plt.title('Pseudo-random', fontsize=20)
plt.subplot(122)
ys = np.array(gen.get(100))
plt.scatter(ys[:, 0], ys[:,1])
plt.axis([-0.05, 1.05, -0.05, 1.05])
plt.title('Quasi-random', fontsize=20);
```

#### Quasi-Monte Carlo integration can reduce variance

```python
h_true = 1 - stats.cauchy().cdf(3)
```

```python
n = 10
x = stats.uniform().rvs((n, 5))
y = 3.0/(np.pi * (9 + x**2))
h_mc = np.sum(y, 0)/n
list(zip(h_mc, 100*np.abs(h_mc - h_true)/h_true))
```

    [(0.0995639331, 2.7851493922),
     (0.1013519751, 1.0392939477),
     (0.1018614640, 0.5418257479),
     (0.1006039674, 1.7696533966),
     (0.1030816150, 0.6495373689)]

```python
gen1 = ghalton.Halton(1)
x = np.reshape(gen1.get(n*5), (n, 5))
y = 3.0/(np.pi * (9 + x**2))
h_qmc = np.sum(y, 0)/n
list(zip(h_qmc, 100*np.abs(h_qmc - h_true)/h_true))
```

    [(0.1026632536, 0.2410466633),
     (0.1023042949, 0.1094428682),
     (0.1026741252, 0.2516617574),
     (0.1029118212, 0.4837496311),
     (0.1026111501, 0.1901724534)]


##  Taylor problem 5.2

last revised: 14-Jan-2019 by Dick Furnstahl [furnstahl.1@osu.edu]

The potential energy for two atoms in a molecule can sometimes be approximated by the Morse function

$\begin{align}
  U(r) = A\left[ \left(e^{(R-r)/S} -1\right)^2 -1 \right]
\end{align}$

for $0 < r < \infty$ with $A$, $R$, and $S$ all positive constants and $S \ll R$.

The goals are to find the equilibrium position $r_0$ and then the frequency of small oscillations.

Take the derivative and solve for the equilibrium $r_0$:

$\begin{align}
 \frac{dU}{dr} = 2A\left(e^{(R-r)/S} -1\right)\times e^{(R-r)/S} \times \frac{-1}{S} = 0
 \quad\Longrightarrow\quad
 r_0 = R
  \quad\Longrightarrow\quad
 U(r_0) = -A
\end{align}$

Expand around $r = r_0$ up to $(r - r_0)^2$ to find the small angle approximation equation and frequency:

$\begin{align}
U_{sa}(r) = A + \frac{A}{S^2} (r - r_0)^2
 \quad\Longrightarrow\quad
 k = \frac{2 A}{S^2}
 \quad\Longrightarrow\quad
 \omega = \sqrt{k/m} = \sqrt{\frac{2 A}{ m S^2}}
\end{align}$

## Make a plot to check $r_0$

```python
%matplotlib notebook
# Use notebook rather than inline so plot remains active between
#  cells and so we can zoom in.
```

```python
import numpy as np

import matplotlib.pyplot as plt
```

```python
def U_pot(r, S=0.1, big_R=1, A=1):
    """Potential from Taylor problem 5.13."""
    return A * ( (np.exp((big_R - r)/S) - 1.)**2 - 1. )

def U_pot_min(S=0.1, big_R=1, A=1):
    """Returns the value of r at the minimum of U(r) and the value of
       U(r) at that point."""
    return big_R,  -A
```

```python
def U_pot_sa(r, S=0.1, big_R=1, A=1):
    """Plot the potential in the small angle approximation"""
    r0 = big_R
    return  -A + A * (r - r0)**2 / S**2

```

```python
r_pts = np.arange(0.01, 6., .001)
U_pot_1 = U_pot(r_pts, S=0.2)
U_pot_2 = U_pot(r_pts, S=0.5)
#U_pot_3 = U_pot(r_pts, S=0.3)

fig = plt.figure(figsize=(4,4))
ax = fig.add_subplot(1,1,1)
ax.plot(r_pts, U_pot_1, label=r'$S=0.2$', color='blue')
ax.plot(r_pts, U_pot_2, label=r'$S=0.5$', color='red')
#ax.plot(r_pts, U_pot_3, label=r'$S=0.3$', color='green')

ax.set_xlim(0.,2.5)
ax.set_ylim(-2.,5.)

x0, y0 = U_pot_min(S=0.2)
ax.scatter(x0, y0, color='blue')
x0, y0 = U_pot_min(S=0.5)
ax.scatter(x0, y0, color='red')
#x0, y0 = U_pot_min(S=0.3)
#ax.scatter(x0, y0, color='green')

ax.set_xlabel('r')
ax.set_ylabel('U(r)')

ax.legend()
fig.tight_layout()
```

    <IPython.core.display.Javascript object>

Now put in small angle potentials (note that this changes the figure above, which is still the active figure (because we are using `%matplotlib notebook`).  Use the controls to zoom in to verify that the small-angle approximated $U(r)$ really does agree for $r$ close enough to the minimum.

```python
# "sa" here stands for "small-angle".
U_pot_sa_1 = U_pot_sa(r_pts, S=0.2)
U_pot_sa_2 = U_pot_sa(r_pts, S=0.5)
#U_pot_sa_3 = U_pot_sa(r_pts, S=0.3)
ax.plot(r_pts, U_pot_sa_1, color='blue', ls='--')
ax.plot(r_pts, U_pot_sa_2, color='red', ls='--')
#ax.plot(r_pts, U_pot_sa_3, color='green', ls='--')

```

    [<matplotlib.lines.Line2D at 0x117fb7f98>]

Can we solve for exact and small angle oscillations numerically?

```python

```


# Simulating Crack Propagation

In this notebook, we will examine some of the equations and numerical techniques for simulating **dynamic crack propagation**. The context will be **earthquake source mechanics** but much of the approach can be generalised to other problems.

## Basic Equations

From the notes on Earthquakes, we derived a frequency space expression for the stress change on a slipping crack under the quasidynamic assumption.

\begin{equation}
\hat{\tau} = \hat{\tau}_0 - \frac{i\mu\omega}{2c}\hat{\delta}-\frac{\mu|k|}{2}\hat{\delta}.
\end{equation}

The corresponding equation in real space is

\begin{equation}
\tau=\tau_0-\frac{\mu}{2c}V - \frac{\mu}{2\pi}\int\limits_{-\infty}^\infty \frac{d\delta/dx'}{x-x'}dx'.
\end{equation}

where $V=d\delta/dt$ is called the slip velocity.

## Part 1: Computing Slip-induced Stress Changes

To solve dynamic crack problems, we need a method to compute the integral term above. Recall, this is the stress change on the crack resulting from slip, $\delta$, after all seismic waves have dissipated. It is only realised at very long time, and is sometimes referred to as the 'quasistatic term'.

One way to solve this term, for arbitrary slip function, is by directly evaluating the integral.

Let's see how that works. ***First, execute the cell below to create a slip profile.***

```python
%matplotlib inline
import numpy as np
from matplotlib import pyplot as plt
x = np.linspace(0,1.e3,128)      # spatial coordinate (1 km)
c,a,umax = 500,250,1             # center and aperture of crack, maximum slip
delta = np.array([np.sqrt(a**2 - (xi-c)**2)/a*umax if abs(xi-c)<a else 0 for xi in x ])   # elliptical slip profile
f,ax = plt.subplots(1,1)
ax.plot(x,delta,'k-')
ax.set_xlabel('x [m]')
ax.set_ylabel('slip [m]')
plt.show()
```

Now, compute the integral **at each $x$** using the Rectangular rule.

```python
xm = 0.5*(x[:-1]+x[1:])              # cell mid-points
ddelta = np.diff(delta)/np.diff(x)   # slip gradient at midpoints
dts = 0.*x                           # empty vector to compute quasistatic stress change at midpoints
dx = x[1]-x[0]
mu = 30.e9                           # typical shear modulus of rock, 30 GPa
for i,xi in enumerate(x):           # loop over each value xm and compute dts
    dts[i] = -mu/2./np.pi*np.sum(ddelta/(xi-xm))*dx     # Rectangular rule

f,ax = plt.subplots(1,1)
ax.plot(x,delta,'k-')
ax.set_xlabel('x [m]')
ax.set_ylabel('slip [m]')
ax1=ax.twinx()
ax1.plot(x,dts/1.e6,'b-')
ax1.set_ylabel('stress change [MPa]')
plt.show()
```

An alternative way to compute the quasistatic stress term is to recognise that the integral is formulated as a convolution. Therefore, we can ***multiply*** the Fourier transforms of the terms $1/x$ and $\delta$ with appropriate frequency space terms corresponding to the spatial derivative $d/dx$.

\begin{equation}
\int\limits_{-\infty}^\infty \frac{d\delta/dx'}{x-x'}dx' \Longleftrightarrow \pi\,|k|\,\hat{\delta}
\end{equation}

The algorithm then becomes

1. Compute (absolute values of) the spatial wavenumbers, $|k|$. This needs only be done once.
2. Take the Discrete Fourier Transform (DFT) of the slip vector, $\delta$. This needs to be done each timestep, because $\delta$ evolves with time.
3. Multiply $|k|$ and $\hat{\delta}$.
4. Take the Inverse Discrete Fourier Transform (IDFT) of the product.

We will use the Fast Fourier Transform algorithm to compute the DFTs and IDFTs.

```python
k = np.fft.fftfreq(len(x), x[1]-x[0])      # get the wave numbers - do this once
deltahat = np.fft.fft(delta)               # FFT of slip vector - do this each time step
dts_FFT = -mu/2.*np.fft.ifft(abs(k)*deltahat)*2*np.pi   # inverse transform of product

f,ax = plt.subplots(1,1)
ax.plot(x,delta,'k-')
ax.set_xlabel('x [m]')
ax.set_ylabel('slip [m]')
ax1=ax.twinx()
ax1.plot(x,dts/1.e6,'b--',label='direct')
ax1.plot(x,dts_FFT/1.e6,'g-', label = 'FFT')
ax1.set_ylabel('stress change [MPa]')
ax1.legend()
plt.show()
```

Not the same, but similar. The differences arise due to a periodic boundary condition when implementing the FFT method. This assumes that slip is not localised to the crack, but rather is an infinitely tesselating pattern (an infinite number of slipping cracks, all affecting each other). One way to counteract this effect is to ***zero pad*** the slip vector.

***Change the crack width from 250m to 25m and note how agreement between the FFT and direct evaluation improve***.

***Define some other slip functions and recomputing the quasistatic stress change.***

- ** A sinusoid.**
- ** A square function.**
- ** A Gaussian. **

***What causes a stress singularity?***

What motivates an FFT method - why should we bother? The main reason is some quite significant gains in **computational efficiency**.

The code below simulates many repetitions of the direct integration and FFT methods for different vector lengths, $N$.

***Execute the cell below to analyse scaling performance for the two methods***

```python
from time import time

def dts_direct(x,u,reps):
    ''' return time to evaluate direct integral REPS times
    '''
    # start the timer
    tstart = time()

    # outside loop, calculations done once at beginning of simulation
    xm = 0.5*(x[:-1]+x[1:])
    mu = 30.e9
    dx = (x[1]-x[0])*-mu/2./np.pi
    dts = 0.*x
    for i in range(reps):
        # inside loop, calculations that change each time
        ddelta = np.diff(delta)/np.diff(x)
        for i,xi in enumerate(x):
            dts[i] = np.sum(ddelta/(xi-xm))*dx

    # end the timer
    tend = time()

    return (tend-tstart)/reps

def dts_FFT(x,u,reps):
    ''' return time to evaluate FFT integral REPS times
    '''
    # start the timer
    tstart = time()

    # outside loop, calculations done once at beginning of simulation
    mu = 30.e9                           # typical shear modulus of rock, 30 GPa
    absk = -mu/2.*abs(np.fft.fftfreq(len(x), x[1]-x[0]))*2*np.pi      # get the wave numbers
    for i in range(reps):
        # inside loop, calculations that change each time
        deltahat = np.fft.fft(delta)               # FFT of slip vector
        dts_FFT = np.fft.ifft(absk*deltahat)

    # end the timer
    tend = time()

    return (tend-tstart)/reps

# loop over different size vectors and record execution times
n = 2**np.array(range(6,12))
time_direct = []
time_FFT = []
for ni in n:
    x = np.linspace(0,1.,ni)      # spatial coordinate (1 km)
    delta = np.random.rand(ni)    # slip function (not important what)
    time_FFT.append(dts_FFT(x,delta,1000))     # have to do heaps of REPS to register on system timing
    time_direct.append(dts_direct(x,delta,100))  # not for the direct integration though, so slow...

# plot scalings
f,ax = plt.subplots(1,1)
ax.plot(n, time_direct, 'ks',label='direct integration')
ax.plot(n, time_FFT, 'ko', mfc = 'w',label='FFT')
ax.legend()
ax.set_xlabel('vector length')
ax.set_ylabel('execution time [s]')
ax.set_xscale('log')
ax.set_yscale('log')
plt.show()
```

*Estimate the gradients for curves fitted to the points above. Hence propose scaling laws for the two methods.*

*Do some Googling and see how your answer compares.*

## Part 2: Solving for Crack Propagation

To solve the dynamic crack problem, we first need to formulate some evolution equations for the variables of interest. These will be **friction**, $f(x,t)$, and **slip**, $\delta(x,t)$.

Recall, we introduced a **linear slip-weakening** friction formulation

\begin{equation}
f=\begin{cases}
      f_s - (f_s-f_d)\delta/\delta_c & \delta\leq \delta_c \\
      f_d & \delta > \delta_c
\end{cases}
\end{equation}

Therefore, friction evolution is governing directly by slip evolution

\begin{equation}
\frac{df}{dt}=\begin{cases}
      - (f_s-f_d)V/\delta_c & \delta\leq \delta_c \\
      0 & \delta > \delta_c
\end{cases}
\end{equation}

where $V=d\delta/dt$. Furthermore, we can rearrange the stress equation introduced at the beginning of this notebook in terms of $V=d\delta/dt$ explicitly

\begin{equation}
\frac{d\delta}{dt}=\frac{2c}{\mu}\left(\tau_0-f\sigma_n - \frac{\mu}{2\pi}\int\limits_{-\infty}^\infty \frac{d\delta/dx'}{x-x'}dx'\right).
\end{equation}

where we are **requiring** that fault stress, $\tau$, at all times is **equal** to the (evolving) frictional strength, $f\sigma_n$.

Thus, we have a pair of **coupled ODEs** that can be solved according to whichever timestepping method you prefer.

For example, an **improved Euler step**:

1. Specify friction, $f_0$, and slip, $\delta_0$, at time $t$.
2. Compute velocity, $V_0$, at time $t$, using $f_0$ and $\delta_0$.
2. Compute predictor friction, $f_1^P$, at time, $t+dt$, using $V_0$.
3. Compute predictor slip, $\delta_1^P$, at time, $t+dt$, using $V_0$.
4. Compute predictor velocity, $V_1^P$, at time $t+dt$, using $f_1^P$ and $\delta_1^P$.
5. Compute corrector velocity, $V_1^C=(V_0+V_1^P)/2$.
6. Compute corrector friction, $f_1^C$, and slip, $\delta_1^C$, at time $t+dt$, using $V_1^C$.

These equations have been implemented for you in the Python module `qk2.py`.

***Execute the cell below to create and visualise an initial stress state for your fault (shear and normal stress, static and dynamic frictions).***

```python
# CELL 1: CREATE INITIAL STRESS STATE

from qk2 import*

# create 1D mesh (power of 2 spacing for the FFT methods)
    # 5 km long fault
L = 5.e3
    # power of 2 grid points
x = np.linspace(0,L,2**10+1)
x = 0.5*(x[1:]+x[:-1])

# assign initial stress state
    # normal stress
sn = 30.e6
    # static and dynamic friction coefficients
fs,fd = 0.8, 0.6
    # constant, just below the static strength but above dynamic
s = (fs-0.01)*sn+0.*x             # the '+0.*x' is just a hacky way of getting the vector the right length...

# zero pad the stress vector on both ends to avoid FFT reflection problems
s[np.where(abs(x-L/2.)>L/8.)]=0

# create a fault object with this initial stress state
flt = Fault(x, s, fs=fs, fd=fd, normal=sn, dc=1e-2, dtn=30000, tmax=10.)

# plot the initial stress state
flt.show_stress(xlim = [L/4,3*L/4])
```

***Execute the cell below to trigger an earthquake on your fault. Visualise how slip, stress, and friction evolve over time.***

```python
# CELL 2: SIMULATE AN EARTHQUAKE (CRACK PROPAGATION) AND VIEW THE OUTPUT

# trigger an earthquake halfway along the fault
flt.trigger(x=L/2.)

# have a look at the earthquake
flt.show_eq(xlim = [L/4,3*L/4])
```

***Referring to the top left plot, explain why the crack (earthquake) stops propagating where it does.***

*your answer here*

***Referring to the right middle plot, identify periods of nucleation, propagation and arrest.***

*your answer here*

***Is the final slip profile consistent with expressions derived in the notes?***

*your answer here*

***Is the length of the nucleation period consistent with numbers derived in the notes?***

*your answer here*

***Experiment with different initial stress configurations and triggering locations.***

- **What happens if we don't trigger at the center of the positive stress drop region?**
- **What happens if we have multiple regions of positive and negative stress drop?**
- **What happens if the stress drop is larger?**

Hint: getting the right time output in these simulations is tricky. Experiment with the parameters `dtn` and `tmax`, while inspecting the right middle plot. You want to set these parameters not too much larger/longer than the length of the earthquake.

```python
# CELL 3: EXPERIMENT
# create 1D mesh (power of 2 spacing for the FFT methods)
    # 5 km long fault
from qk2 import*
L = 5.e3
    # power of 2 grid points
x = np.linspace(0,L,2**10+1)
x = 0.5*(x[1:]+x[:-1])

# assign initial stress state
    # normal stress
sn = 30.e6
    # static and dynamic friction coefficients
fs,fd = 0.8, 0.6
    # constant, just below the static strength but above dynamic
s = (fs-0.01)*sn+0.*x             # the '+0.*x' is just a hacky way of getting the vector the right length...

# zero pad the stress vector on both ends to avoid FFT reflection problems
s[np.where(abs(x-L/2.)>L/8.)]=0

# create a fault object with this initial stress state
flt = Fault(x, s, fs=fs, fd=fd, normal=sn, dc=1e-2, dtn=30000, tmax=10.)

# plot the initial stress state
    # OPTIONAL
#flt.show_stress(xlim = [L/4,3*L/4])

# trigger an earthquake halfway along the fault
flt.trigger(x=2.5e3)

# have a look at the earthquake
flt.show_eq(xlim = [L/4,3*L/4])
```

## Timestep control

The physics of crack nucleation and propagation occur on substantially **different time scales**, which presents a challenge to time stepping methods. Should we set the timestep small, to **accurately capture** rapid evolution during crack propagation, but take an **unnecessarily large number** of timesteps during nucleation? Or is it better to **step rapidly** through nucleation, and **resolve poorly** the rupture phase?

Ideally what we'd like is a timestep that changes after each improved Euler update, in a way that responds to the system evolution.

### The Proportional-Integral-Derivative (PID) controller

This a control feedback mechanism used to **adjust a process toward some desirable setpoint** on the basis of a computed error value, $e(t)$. The key is that the imposed adjustment (in this, changing the timestep) depends both on the size of the error, $e$ (the **proportional** part), its recent history, $\int e dt$, (the **integral** part) and how rapidly it is changing, $de/dt$, (the **derivative** part).

Fortunately, an improved Euler step embeds an error estimate within itself.

\begin{equation}
e(t) = \max\left\lvert\frac{U_1^C-U_1^P}{U_1^C}\right\rvert
\end{equation}

The timestep is then updated by multiply by a ratio that depends on the PID terms

\begin{equation}
dt_{i+1} = dt_i \left(\frac{\epsilon_r}{e_i}\right)^\alpha \left(\frac{e_{i-1}}{e_i}\right)^\beta \left(\frac{e_{i-1}^2}{e_{i-2}e_{i}}\right)^\gamma
\end{equation}

where $\epsilon_r$ is the relative error tolerance.

Taking the log of both sides

\begin{equation}
\log(dt_{i+1}) = \log(dt_i)+\alpha \left[\log\epsilon_r-\log e_i\right] + \beta \left[\log e_{i-1}-\log e_i\right]+\gamma\left[-\log e_{i-2}+2\log e_{i-1}-\log e_i\right].
\end{equation}

We can recognize:

- $\log\epsilon_r-\log e_i$, the integral term, the total error at time $t$.
- $\log e_{i-1}-\log e_i$, the proportional term, the accumulated error at this time step.
- $-\log e_{i-2}+2\log e_{i-1}-\log e_i$, the derivative term, the rate at which the error is changing.

The powers $\alpha$=0.17, $\beta$=0.245 and $\gamma$=0.05 are selected based on analysis and experience.


# Planck's law

Let's briefly introduce the main aspects of the theory combining some Wikipedia articles. Links below.

Planck's law describes the spectral density of electromagnetic radiation emitted by a black body in thermal equilibrium at a given temperature, when there is no net flow of matter or energy between the body and its environment.

Planck's law can be written in terms of the spectral energy density. These distributions have units of energy per volume per spectral unit.

\begin{equation}
\rho (\lambda, T) = \frac{8 \pi h c}{\lambda^5 \left(e^{\frac{hc}{\lambda k_B T}} - 1\right)}
\label{eq:planck}
\end{equation}

where $k_B$ is the Boltzmann constant, $h$ is the Planck constant, and $c$ is the speed of light in the medium, whether material or vacuum.

Black-body radiation is the thermal electromagnetic radiation within or surrounding a body in thermodynamic equilibrium with its environment, emitted by a black body (an idealized opaque, non-reflective body). It has a specific spectrum of wavelengths, inversely related to intensity that depend only on the body's temperature, which is assumed for the sake of calculations and theory to be uniform and constant.

References: [Planck's law](https://en.wikipedia.org/wiki/Planck%27s_law), [Black-body radiation](https://en.wikipedia.org/wiki/Black-body_radiation), [Energy density](https://en.wikipedia.org/wiki/Energy_density)

# About this piece of software

A simple script to plot the Planck's law.

Please, run the following cell so that the script can be imported (the notebook must be in the same folder as the script). Then, run each code cell after reading their meaning.

```python
import planck
```

The following must be imported so that we can use them in the examples.

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

# the following must be imported for interactive plots
from ipywidgets import interact, interactive, fixed
```

Now let's test the methods of the planck module.

We can use the `planck_energy_density` function to calculate the energy density at a given wavelength at a given temperature. Remember that the module uses SI units. For example, at 500 nm and 7000 K:

```python
planck.planck_energy_density(500.0e-9, 7000)
```

    2662877.9681075383

Usually we want the energy density at a given wavelength _range_ in order to plot a distribution. The function accepts wavelengths arrays.

Let's create a wavelength array with 10 values between 400 and 800 nm and calculate the energy density values at 5000 K:

```python
lambda_example = np.linspace(400e-9, 800e-9, 10)

energy_density_5000K_example = planck.planck_energy_density(lambda_example, 5000)
energy_density_5000K_example
```

    array([366503.0978276 , 444693.41497869, 498031.66222402, 527350.02877923,
           536480.59647891, 530167.30064167, 512920.45172757, 488528.23748235,
           459942.12455755, 429341.64031601])

Great. Now we have data points that can be plotted. Let's create a figure and uses Matplotlib to draft a plot.

```python
# plot draft

fig_ex1 = plt.figure(figsize=(8,4))
ax_ex1 = fig_ex1.add_subplot(111)

ax_ex1.plot(lambda_example, energy_density_5000K_example)
plt.show()
```

Yeah, it resembles a Planck distribution. You can modify the plot using Matplotlib's methods to be more suitable for presentation (axes labels, wavelengths in nanometers etc). You probably should increase the number of points to get a smoother plot.

However, the planck module has a function for plots that can do all this!

The function receives a wavelength array and a temperature array/list. So, let's plot the same range again but using it and increasing the number of data points to 1000 (probably overkill but hey it's a computer doing math).

The function always get the current axis to plot, so we are going to create a figure with axes before calling the function.

```python
lambda_example = np.linspace(400e-9, 800e-9, 1000)

fig_ex2 = plt.figure(figsize=(8,4))
ax_ex2 = fig_ex2.add_subplot(111)

planck.plot_planck(lambda_example,[5000])
```

The function generates the labels and a decent scale. The wavelength unit is nanometers by default.

Since the function receives a temperature _list_, more than one value can be passed. Let's test this feature passing 5000 and 6000 K.

```python
fig_ex3 = plt.figure(figsize=(8,4))
ax_ex3 = fig_ex3.add_subplot(111)

planck.plot_planck(lambda_example,[5000, 6000])
```

Great. Let's increase the wavelength range and pass more temperatures using a numpy array:

```python
lambda_array = np.linspace(1.0e-9, 2.0e-6, 1000)
temperature_array = np.arange(1000, 7001, 500)

fig1 = plt.figure(figsize=(10, 6))
ax = fig1.add_subplot(111)
planck.plot_planck(lambda_array, temperature_array)
```

By default, the method uses the coolwarm [colormap](https://matplotlib.org/3.1.1/gallery/color/colormap_reference.html). Let's change it to gist_rainbow:

```python
fig2 = plt.figure(figsize=(10, 6))
ax = fig2.add_subplot(111)

planck.plot_planck(lambda_array, temperature_array, colors=plt.cm.gist_rainbow)
```

The planck module also has a function that can be called _before_ the plot. It's the `plot visible` one and it adds a visible spectrum in the plot background so that we can see lower temperatures bodies with maximum wavelength emission near the red (or even before, infrared) and higher ones towards the violet and beyond (ultraviolet):

```python
fig3 = plt.figure(figsize=(10, 6))
ax = fig3.add_subplot(111)

planck.plot_visible()
planck.plot_planck(lambda_array, temperature_array)
```

Last but not the least, the module has a function to  interactive plots: `plot_planck_interactive`. This function must be passed to the `interactive` method from ipywidgets package. A wavelength array must also be passed as a fixed argument. The temperature must be passed as follows with a initial value (500 in the example), a final value (8000) and a step (500).

```python
lambda_array = np.linspace(1.0e-9, 3.0e-6, 1000)
graph = interactive(planck.plot_planck_interactive,
                    wavelength_array=fixed(lambda_array),
                    temperature=(500,8000,500))
display(graph)
```

    interactive(children=(IntSlider(value=500, description='temperature', max=8000, min=500, step=500), Output()),…

Let's change the values to see the effects.

```python
lambda_array = np.linspace(200e-9, 900e-9, 1000)
graph = interactive(planck.plot_planck_interactive,
                    wavelength_array=fixed(lambda_array),
                    temperature=(3000,9000,400))
display(graph)
```

    interactive(children=(IntSlider(value=3000, description='temperature', max=9000, min=3000, step=400), Output()…

```python

```


# Standart Derivatives Table
This table contains the fundamental derivatives, which need to be known so that other functions can be derived.

| $f(x)$ | $\frac{df}{dx}$ |
|--------|-----------------|
| $k$ (constant) | $0$ |
| $x$ | $1$ |
| $x^k$ | $kx^{k-1}$ |
| $e^x$ | $e^x$ |
| $k^x$ | $k^x \ln{k}$ |
| $\sqrt{x}$ | $\frac{1}{2\sqrt{x}}$ |
| $\ln{x}$ | $\frac{1}{x}$ |
| $\log_k{x}$ | $\frac{1}{x\ln{k}}$ |
| $\sin{x}$ | $\cos{x}$ |
| $\cos{x}$ | $-\sin{x}$ |
| $\tan{x}$ | $\sec^2{x}$ |
| $\arcsin{x}$ | $\frac{1}{\sqrt{1-x^2}}$ |
| $\arccos{x}$ | $-\frac{1}{\sqrt{1-x^2}}$ |
| $\arctan{x}$ | $\frac{1}{1+x^2}$ |
| $\sinh{x}$ | $\cosh{x}$ |
| $\cosh{x}$ | $\sinh{x}$ |
| $\tanh{x}$ | $1 - \tanh^2{x}$ |
| $\text{arsinh}{x}$ | $\frac{1}{\sqrt{x^2+1}}$ |
| $\text{arcosh}{x}$ | $\frac{1}{\sqrt{x^2-1}}$ |
| $\text{artanh}{x}$ | $\frac{1}{1- x^2}$ |

# Practice with Python
## Symbolic derivatives with SymPy
This practice consists of generating this same table using the symbolic mathematical package SymPy. The first step is to import the package.

```python
from sympy import *
```

Now, let's define the variables and symbolic constants that we will use, for that we use the Symbol class.

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

Now, let's define a list of functions $f(x)$.

```python
functions = [
    k,
    x,
    x**k,
    exp(x),
    k**x,
    sqrt(x),
    ln(x),
    log(x, k),
    sin(x),
    cos(x),
    tan(x),
    asin(x),
    acos(x),
    atan(x),
    sinh(x),
    cosh(x),
    tanh(x),
    asinh(x),
    acosh(x),
    atanh(x)
]
```

Now, let's generate a table of symbolic derivatives. To derive SymPy's symbolic expressions we use **diff**.

```python
for function in functions:
    derivative = diff(function, x)
    print(f'f(x) = {function} \t df/dx = {derivative}')
```

    f(x) = k 	 df/dx = 0
    f(x) = x 	 df/dx = 1
    f(x) = x**k 	 df/dx = k*x**k/x
    f(x) = exp(x) 	 df/dx = exp(x)
    f(x) = k**x 	 df/dx = k**x*log(k)
    f(x) = sqrt(x) 	 df/dx = 1/(2*sqrt(x))
    f(x) = log(x) 	 df/dx = 1/x
    f(x) = log(x)/log(k) 	 df/dx = 1/(x*log(k))
    f(x) = sin(x) 	 df/dx = cos(x)
    f(x) = cos(x) 	 df/dx = -sin(x)
    f(x) = tan(x) 	 df/dx = tan(x)**2 + 1
    f(x) = asin(x) 	 df/dx = 1/sqrt(1 - x**2)
    f(x) = acos(x) 	 df/dx = -1/sqrt(1 - x**2)
    f(x) = atan(x) 	 df/dx = 1/(x**2 + 1)
    f(x) = sinh(x) 	 df/dx = cosh(x)
    f(x) = cosh(x) 	 df/dx = sinh(x)
    f(x) = tanh(x) 	 df/dx = 1 - tanh(x)**2
    f(x) = asinh(x) 	 df/dx = 1/sqrt(x**2 + 1)
    f(x) = acosh(x) 	 df/dx = 1/sqrt(x**2 - 1)
    f(x) = atanh(x) 	 df/dx = 1/(1 - x**2)


## Basics of Differential Equations

(This presentation assumes knowledge of differential and integral calculus of a single variable. For those who have studied diffEQs before, this material covers first order, non-homogeneous, ordinary diffEQs. If you don't know what that means, no worries. Prior exposure to diffEQs is neither assumed nor necessary. If viewing this as a Jupyter notebook, select Cell > Run All so all the libraries are loaded before re-running any of the cells.)

The study of differential equations began with real world problems. Consider how a relatively hot object, like a cup of coffee, cools to the abient room temperature when left on a table for a long enough period of time. How do we model this mathematically?

For some object, assume the temperature _T_ at some time _t_ decreases to some ambient temperature (we'll assume 0 for simplicity). When taking the temperature at time 0, the value might be 180 degrees. At time 10, it might be 144 degrees. At time 20, it might be 116 degrees. At time 30, it might be 92 degrees, and so on.

In looking at the data, you might notice that for each time interval of 10, the temperature is 20% lower than it was at the last measurement. Continuing the trend and graphing, we have this:

```python
# the examples in this notebook are designed to be evaluated in sequence from beginning to end

# uncomment these to filter Matplotlib deprecation warnings for sympy
import warnings
warnings.filterwarnings('ignore')

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

t = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
T = [180, 144, 116, 92, 74, 59, 47, 37, 29, 23, 19, 15, 12]
plt.scatter(t,T)
plt.xlabel("t",labelpad=10)
plt.ylabel("T",labelpad=10)
plt.axis('square');
plt.show()
```

If we imagine a continuous function that connects these points, at any of the points, the rate of change of the function of temperature is given by this equation:

$
\hspace{0.5in} T(t) = 0.8T_{(t - 10)}
$

Understand what this means. At any time _t_, the current temperature is 80% of the previous temperature taken 10 time units ago. If we change this internal to 1 time unit ago and assume a linear interpolation of the _T_ values (this is just an approximation, because the function clearly isn't piecewise-linear), just take -20% and divide it by 10, and the change is -2% per time unit.

Taking this incremental approximation and somewhat cavalierly converting it to an equation involving the time rate of change of _T_ (or the derivative of _T_ with respect to _t_), we have this:

$
\hspace{0.5in} T'(t) = -0.02T(t)
$

or, equivalently:

$
\hspace{0.5in}(1)\hspace{0.25in}T'(t) + 0.02T(t) = 0,\ T(0) = 180
$

The question is, what is $T(t)$, such that this equation is true? (In other words, what is the approximate equation fitting the data shown above?)

The previous equation is called an unforced linear differential equation, and it has this general form:

$
\hspace{0.5in}(2)\hspace{0.25in}y'(t) + ry(t) = 0
$

This form is foundational in the study of differential equations, and it models many phenomena.

This is the solution for equation (2), where $s = y(0)$, and _s_ can be considered a starting value when _t_ represents time:

$
\hspace{0.5in}(3)\hspace{0.25in}y(t) = se^{-rt}
$

(Note: Equations are frequently referred to simply by "(N)", where N is an integer.)

Given a value _r_, to solve (2), a $y(t)$ is needed that gives solutions for all _t_, and (3) is such a solution.

To show this, assume this is a solution:

$
\hspace{0.5in}(4)\hspace{0.25in}g(t) = ke^{zt}
$

Now substitute (4) into (2) and do the differentiation:

$
\hspace{0.5in} kze^{zt} + rke^{zt} = 0
$

Solving for _z_, we get $z = -r$. By substituting _-r_ for _z_ in (4) and letting $k = s$, (3) is confirmed as the solution.

Here is a method for doing this in Python:

```python
import sympy as sym
r, t = sym.symbols('r t')
g = sym.Function('g')
ode = sym.Eq(sym.diff(g(t), t), -r*g(t))
sol = sym.dsolve(ode, g(t))
sol
```

$\displaystyle g{\left(t \right)} = C_{1} e^{- r t}$

This result is equivalent to (4) or (3).

Now we verify (3) produces 0 if substitued into (2):

```python
r, s, t = sym.symbols('r s t')
y = s*sym.exp(-r*t)
print(sym.diff(y, t) + r*y)
```

    0

So (3) is a solution of (2).

This information can be used to solve our original temperature cooling equation:

$
\hspace{0.5in}(1)\hspace{0.25in}T'(t) + 0.02T(t) = 0,\ T(0) = 180
$

We now know the general form of the solution is $T(t) = se^{-0.02t}$. With $t = 0$, from our original data we have $180 = se^{-0.02*0}$; so $s = 180$. The solution is:

$\hspace{0.5in}(5)\hspace{0.25in}T(t) = 180e^{-0.02t}$

Here is a graph of the solution versus the original data:

```python
# legend font size adjustment
SMALL_SIZE=12
plt.rc('legend', fontsize=SMALL_SIZE)

# domain
t = np.linspace(-1,120,120)
# the solution to the approximate equation
T = 180*np.exp(-0.02*t)

# attributes of the graph axes
fig, ax = plt.subplots()
ax.spines['left'].set_position('zero')
ax.spines['right'].set_color('none')
ax.spines['bottom'].set_position('zero')
ax.spines['top'].set_color('none')

# plot the function
plt.plot(t,T, 'y', label="T = $180e^{-0.02t}$")
plt.axis('square');
plt.xlabel("t",labelpad=10)
plt.legend(loc='upper right')

# plot the dots
t = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
T = [180, 144, 116, 92, 74, 59, 47, 37, 29, 23, 19, 15, 12]
plt.scatter(t,T);

```

Just for a sanity check against the original data, let's calculate _T(50)_ and _T(100)_. For the original data set, these values are 59 and 19, respectively.

```python
print("{0:.1f}".format(180*np.exp(-0.02*50)))
print("{0:.1f}".format(180*np.exp(-0.02*100)))
```

    66.2
    24.4

As we said initially, equation (1) is an approximation, and the solution falls on the high side of the data. Nevertheless, this example is a useful beginning for exploring this type of differential equation.

The preceding example represents something called called exponential decay. It relates to things like temperature cooling, capacitor discharge, and half-life of a radioactive element, among other natural phenomena.

If $r < 0$, then the differential equation would be this:

$
\hspace{0.5in} T'(t) - 0.02T(t) = 0
$

and the solution would be:

$
\hspace{0.5in} T(t) = 180e^{0.02t}
$

with the following graph:

```python
t = np.linspace(-1,120,120)
T = 180*np.exp(0.02*t)

fig, ax = plt.subplots()
ax.spines['left'].set_position('zero')
ax.spines['right'].set_color('none')
ax.spines['bottom'].set_position('zero')
ax.spines['top'].set_color('none')
plt.plot(t,T, 'y', label="T = $180e^{0.02t}$")
plt.xlabel("t",labelpad=10)
plt.legend(loc='lower right')
plt.show()
```

This is called exponential growth. It relates to things like population growth, compound interest, and atomic collisions during fission.

Going back to our equation (1), if $s < 0$:

$
\hspace{0.5in} T'(t) + 0.02T(t) = 0,\ y(0) = -180
$

and the solution would be:

$
\hspace{0.5in} T(t) = -180e^{-0.02t}
$

with the following graph:

```python
T = -180*np.exp(-0.02*t)

fig, ax = plt.subplots()
ax.spines['left'].set_position('zero')
ax.spines['right'].set_color('none')
ax.spines['bottom'].set_visible(False)
plt.rcParams['xtick.top'] = plt.rcParams['xtick.labeltop'] = True
plt.plot(t,T, 'y', label="T = $-180e^{-0.02t}$")
plt.legend(loc='lower center')
plt.show()
```

This is another type of exponential growth--one that has an upper bound. This as a reflection of (5) across the x-axis. This graph could represent the warming of an object to an ambient temperature.

Previously, we saw this differential equation:

$
\hspace{0.5in}(2)\hspace{0.25in}y'(t) + ry(t) = 0
$

has solutions of this form:

$
\hspace{0.5in}(4)\hspace{0.25in} g(t) = ke^{-rt}
$

where $g(t)$ can be substituted for $y(t)$. When $r > 0$, $g(t)$ decreases and conversely, when $r < 0$, $g(t)$ increases.

If we change (2) to this:

$
\hspace{0.5in}(6)\hspace{0.25in} y'(t) + ry(t) = f(t)
$

with $y(0) = k$, we have what is called a forced exponential differential equation. Such equations have this solution:

$
\hspace{0.5in}(7)\hspace{0.25in} y(t) = ke^{-rt} + e^{-rt}\int\limits_0^t e^{rs}f(s)\,\mathrm{d} s
$

if the integral can be evaluated (which frequently, it can't).

Let's demonstrate this in Python.

```python
r, s = sym.symbols('r s')
y = sym.Function('y')(s)
f = sym.Function('f')(s)
y_ = sym.Derivative(y, s)
# y' + ry - f = 0
sol = sym.dsolve(y_ + r*y - f, y)
sol
# sym.pprint(sol)
```

$\displaystyle y{\left(s \right)} = \left(C_{1} + \int f{\left(s \right)} e^{r s}\, ds\right) e^{- r s}$

This has the same form as (7) with $C_1 = k$ but without the bounds of integration.

Using (7), we can construct the left side of (6) and evaluate (6) at $t = 0$:

```python
k, s, t = sym.symbols('k s t')
f = sym.Function('f')(t)
# formulate the left side of equation (5)
I = sym.Integral(sym.exp(r*s)*f,(s,0,t))
I_eval = I.doit()
y = sym.expand(sym.exp(-r*t)*(k + I_eval))
# pprint(y)
y_prime = sym.diff(y,t)
s = y_prime + r*y
# pprint(simplify(s))
def eval_s(t_):
    return s.subs([(t, t_)])
print(eval_s(0))
```

    f(0)

As we can see, $y'(0) + ry(0) = f(0)$ when $y(t) = ke^{-rt} + e^{-rt}\int\limits_0^t e^{rs}f(s) \, \mathrm{d} s\,$.

Why does $y(t) = ke^{-rt} + e^{-rt}\int\limits_0^t e^{rs}f(s) \, \mathrm{d} s$ work?

If we start with $y'(s) + ry(s) = f(s)$ and multiply through by $e^{rs}$, we get this:

$\hspace{0.5in} e^{rs}y'(s) + e^{rs}ry(s) = e^{rs}f(s)$

which is the same as this:

$\hspace{0.5in} \frac{\mathrm{d}\,e^{rs}y(s)}{\mathrm{d}s} = e^{rs}f(s)$

Integrating both sides with respect to $s$ on the interval from 0 to $t$, we get $y(t) = ke^{-rt} + e^{-rt}\int\limits_0^t e^{rs}f(s) \, \mathrm{d} s\,$, which we said is the solution to a forced exponential differential equation.

While this is fine on paper, in general, the integral may not be doable analytically. But let's look at some that are doable.

Here is an example forced equation with analytic and graphical solutions:

$\hspace{0.5in} y'(t) + 2y(t) = 3e^{-0.3t}$ for $y(0) = 5$

```python
sym.init_printing()
from sympy.plotting import plot
t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 + 2*y(t) - 3*sym.exp(-0.3*t)
sol1 = sym.dsolve(eqdiff, y(t), ics={y(0): '5'})
sol1
```

```python
# sym.plot(f.rhs); # this works
# %% Plot
xtol = 1e-3
p1 = plot(sol1.rhs, show=False, xlim=[-0.5,10], ylim=[0,10], ylabel='y(t)')
p1.show()
```

Another example:

$\hspace{0.5in} y'(t) - 1.2y(t) = 0.8t$ for $y(0) = 0$

```python
# be sure to run previous cells before running the following examples that depend on sympy!

t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 - 1.2*y(t) - 0.8*t
sol2 = sym.dsolve(eqdiff, y(t), ics={y(0): '0'})
sol2
```

```python
xtol = 1e-3
p2 = plot(sol2.rhs, show=False, xlim=[-0.1,8], ylim=[0,2000], ylabel='y(t)')
p2.show()
```

Another example:

$\hspace{0.5in} y'(t) + 0.5y(t) = \sin(2t)$ for $y(0) = -2$

```python
t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 + 0.5*y(t) - sym.sin(2*t)
sol3 = sym.dsolve(eqdiff, y(t), ics={y(0): '-2'})
sol3
```

```python
xtol = 1e-3
p3 = plot(sol3.rhs, show=False, xlim=[-0.1,10], ylim=[-3.5,2], ylabel='y(t)')
p3.show()
```

This is an example of an equation that yields an integral that cannot be evaluated analytically:

$\hspace{0.5in} y'(t) + 0.5y(t) = 3.7e^{\sin(t)}$ for $y(0) = -3$

```python
t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 + 0.5*y(t) - 3.7*sym.exp(sym.sin(t))
sol4 = sym.dsolve(eqdiff, y(t), ics={y(0): '-3'})
sol4
```

$\int e^{0.5t}e^{sin(t)}\mathrm{dt}$ cannot be solved. Most forced exponential differential equations result in integrals that have no closed form solution, so numerical approximation methods (think Taylor or Maclaurin series) are used instead of analytical ones.

Another notable behavior is if $r > 0$ and $y'(t) + ry(t) = f(t)$, then any solutions settle into the same steady state. If $r < 0$, this is unpredictable.

Here is an example of the $r > 0$ case.

$\hspace{0.5in} y'(t) + ry(t) = 4\sin(3t)$

We will set $r$ to a random value and assign three random values to $y(0)$.

(Run it multiple times.)

```python
import random
r = round(random.uniform(0.1,10),1)
y0_1 = round(random.uniform(-10,0),1)
y0_2 = round(random.uniform(0.1,10),1)
y0_3 = round(random.uniform(-10,10),1)

t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 + r*y(t) - 4*sym.sin(3*t)

sol5 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_1})
p5 = plot(sol5.rhs, show=False, xlim=[-0.1,6], ylim=[-10,10], line_color='red', ylabel='y(t)')
sol6 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_2})
p6 = plot(sol6.rhs, show=False, ylim=[-10,6], line_color='green')
sol7 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_3})
p7 = plot(sol7.rhs, show=False, ylim=[-10,6], line_color='black')
p5.append(p6[0])
p5.append(p7[0])
p5.show()
```

Here is another example. In this case, _p_ is another randomized variable.

$\hspace{0.5in} y'(t) + ry(t) = 6e^{-t} + pt$

```python
r = round(random.uniform(0.1,10),1)
p = round(random.uniform(-10,10),1)
y0_1 = round(random.uniform(-10,0),1)
y0_2 = round(random.uniform(0.1,10),1)
y0_3 = round(random.uniform(-10,10),1)

t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 + r*y(t) - 6*sym.exp(-t) - p*t

sol8 = sym.dsolve(eqdiff, y(t),  ics={y(0): y0_1})
p8 = plot(sol8.rhs, show=False,   xlim=[-0.1,6], ylim=[-10,10], line_color='red', ylabel='y(t)')
sol9 = sym.dsolve(eqdiff, y(t),  ics={y(0): y0_2})
p9 = plot(sol9.rhs, show=False,   xlim=[-0.1,6], ylim=[-10,10], line_color='green')
sol10 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_3})
p10 = plot(sol10.rhs, show=False, xlim=[-0.1,6], ylim=[-10,10], line_color='black')
p8.append(p9[0])
p8.append(p10[0])
p8.show()
```

So why do these cases settle out to common steady-state values?

Consider $y(t) = ke^{-rt} + e^{-rt}\int\limits_0^t e^{rs}f(s) \, \mathrm{d} s\,$ as $t$ gets arbitrarily large. In that case, $ke^{-rt}$ tends to 0 leaving just the second term, which determines the steady-state behavior. More specifically, $f(t)$ in the forced equation $y'(t) + ry(t) = f(t)$ determines it. But if $r < 0$, this is not the case. Consider this equation:

$\hspace{0.5in} y'(t) - 0.1y(t) = sin(2t)$

(Run several times.)

```python
r = -0.1
y0_1 = round(random.uniform(-5,0),1)
y0_2 = round(random.uniform(0.1,5),1)
y0_3 = round(random.uniform(-5,5),1)

t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 + r*y(t) - sym.sin(2*t)

sol11 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_1})
p11 = plot(sol11.rhs, show=False, xlim=[-0.1,6], ylim=[-10,10], line_color='red', ylabel='y(t)')
sol12 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_2})
p12 = plot(sol12.rhs, show=False, xlim=[-0.1,6], ylim=[-10,10], line_color='green')
sol13 = sym.dsolve(eqdiff, y(t), ics={y(0): y0_3})
p13 = plot(sol13.rhs, show=False, xlim=[-0.1,6], ylim=[-10,10], line_color='black')
p11.append(p12[0])
p11.append(p13[0])
p11.show()
```

These solutions do not converge to a steady state.

With $r < 0$, the $ke^{-rt}$ term of the solution equation does not tend to 0 but gets arbitrarily large. This can cause the solutions to diverge.

### Application of First Order Linear Differential Equations

Compound interest is a context is which differential equations apply.

Assume you have \\$1000 that is earning 4% interest. How much money would you have if the interest were compounded annually for 10 years? Here is the equation where $y(t)$ is the balance at time $t$.

$\hspace{0.5in}(8)\hspace{0.25in}y(t) = 1000e^{0.04t}$

```python
t=10
y_t = 1000*np.exp(0.04*t)
print("y(10) =",round(y_t,2))
```

    y(10) = 1491.82

See it graphically:

```python
t = np.arange(0, 10, 0.1)
y = 1000*np.exp(0.04*t)
plt.plot(t, y, 'y', label="y = $1000e^{0.04t}$")
plt.legend(loc='upper left');
```

Differentiation of both sides of (8) yields this:

$\hspace{0.5in}y'(t) = 0.04\times1000e^{0.04t}$

which is equivalent to this:

$\hspace{0.5in}y'(t) = 0.04y(t)$

which is the same as this unforced differential equation with $y(0) = 1000$:

$\hspace{0.5in}y'(t) - 0.04y(t) = 0$

Understand what this means. The rate of change of money with respect to time minus 0.04 times the amount of money are the same. Now suppose you want to add \\$200 to the account each year. Our equation then becomes this:

$\hspace{0.5in}y'(t) − 0.4y(t) = 200, y(0) = 1000$

which we recognize as a forced differential equation. The solution will be of this form:

$\hspace{0.5in}y(t) = 1000e^{0.4t} + e^{0.4t}\int_0^{10}\,e^{−0.4s} \times 200\,\mathrm{d}s$

The symbolic solution in Python is as follows:

```python
t = sym.symbols('t')
y = sym.Function('y')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 - 0.04*y(t) - 200
sol1 = sym.dsolve(eqdiff, y(t), ics={y(0): '1000'})
sol1
```

and with $t = 10$, we have this:

```python
sol1.subs(t, 10)
```

See it graphically:

```python
t = np.arange(0, 10, 0.1)
y = 6000*np.exp(0.04*t) - 5000
plt.plot(t, y, 'y', label="y = $6000e^{0.04t} - 5000$")
plt.legend(loc='upper left');
```

Using the same growth rate and initial value, how much money would you need to contribute on an annual basis to have  \\$10000 in 10 years? This means we want to solve for d:

$\hspace{0.5in}y'(t) − 0.04y(t) = d, y(0) = 1000$

so that $y(10) = 10000$. Here is a solution in Python:

```python
y = sym.Function('y')
t, d = sym.symbols('t d')
y1 = sym.Derivative(y(t), t)
eqdiff = y1 - 0.04*y(t) - d
sol2 = sym.dsolve(eqdiff, y(t), ics={y(0): '1000'})
dval = sym.solve(sol2.subs(t, 10).subs(y(10), 10000), d)
print("d =", dval[0].n(5))
sol3 = sol2.subs(d, dval[0].n(5))
# print(sol3.rhs)
p14 = plot(sol3.rhs, show=False, xlim=[0,10], ylim=[1000,10000], ylabel='y(t)')
p14.show()
```

Written by Dan Liddell. October, 2021.

These sources were consulted in preparing this content and provided ideas, examples, and source code for this material:

[https://personal.math.ubc.ca/~pwalls/math-python/](https://personal.math.ubc.ca/~pwalls/math-python/)

[https://www.scipy.org/docs.html](https://www.scipy.org/docs.html)

[https://stackexchange.com/](https://stackexchange.com/)

[https://stackoverflow.com/](https://stackoverflow.com/)

A generous amount of credit goes to the following:

    Davis, Bill and Jerry Uhl. Differential Equations&Mathematica [sic]. version 6.0.
    Math Everywhere, Inc., 2007. Published as a Mathematica notebook.

    Math 285 -- Introduction to Differential Equations course at University of Illinois at Urbana-Champaign.


# Derivativos de Ações e Commodities
##  Precificação com o Método de Diferenças Finitas
Uirá Caiado. 29 de Junho, 2016

#### Resumo

_Neste projeto vou implementar o método de diferenças finitas explícito para precificar diferentes derivativos. Como o preço de opções geralmente é descrito por equações diferenciais de difusão ou parabólicas, o método de diferenças finitas se mostra adequado, uma vez que é utilizado justamente para encontrar soluções numéricas para equações diferencias. Depois de implementar o modelo, vou compará-lo com os resultados obtidos pela solução analítica de cada instrumento._

## 1. Introdução

Nesta seção dou uma breve descrição sobre o método de diferenças finitas e declaro o problema que será abordado.

### 1.1. O Método Utilizado

Como colocado por Wilmott, encontrar soluções fechadas para precificação de muitos derivativos pode ser difícil, ou mesmo inviável. Como o preço destes instrumentos frequentemente são descritos por equações diferencias parciais, frequentemente utilizam-se métodos numéricos para encontrar uma solução, como [árvores binomiais](https://en.wikipedia.org/wiki/Binomial_options_pricing_model), simulação de [monte carlo](https://en.wikipedia.org/wiki/Monte_Carlo_method) e diferenças finitas.

O método de diferenças finitas encontra o valor de um derivativo calculando-o em todo domínio (factivel) de preços do instrumento base, incluíndo a passagem de tempo até seu vencimento.  Assim, são similares às árvores binomiais. Porém, ao invéz e discretizar os preços do ativo e a passagem do tempo em uma estrutura de árvore, discretiza em um grid.

Diferenças finitas também são muitos boas para lidar com problemas com poucas dimensões e equações diferenciais não lineares (o preço e tempo já são duas, e o Wilmott sugere até quatro). Para muitas dimensões, a implementação começa a ficar complicada e pouco eficiente, coisa que o Monte Carlo lida melhor. Porém, se há exercício antecipado e não linearidade, o método de diferenças finitas acaba se mostrando como solução mais viável.

### 1.2. O Problema

Considerando um ativo-objeto cuja dinâmica do preço satisfaz a seguinte EDE:

$$\frac{\mathrm{d} S_t}{S_t}=\mu\cdot \mathrm{d}t + \sigma\cdot \mathrm{d}W_{t}$$

Deseja-se calcular o preço justo de um derivativo um derivativo com característica europeia cujo payoff é descrito por uma função qualquer $V_T=V(T, S_T)$, onde $T$ é o vencimento do derivativo. $S_T$ é o preço do ativo-objeto em $T$ e é possível negociar qualquer quantidade dele em qualquer instante. Não há custo de transação (corretagem, emolumento, bid-ask spread, etc) e posições vendidas a descoberto no subjacente são permitidas, não havendo custos associados.

Pede-se que se implemente o algoritmo de diferenças finitas explícito e se calcule o preço justo, o Delta e o Gamma de cada instrumento abaixo. Os valores encontrados devem ser comparados com o resultado de suas expressões analítica. A simulação deve ser feita para os payoffs abaixo. $K$ é o Strike da opção.
- $V(T, S_T)=ln(S_T)$
- $V(T, S_T)=(ln(S_T))^2$
- $V(T, S_T)=(S_T-K)^2$
- $V(T, S_T)=\mathbf{1}_{S_T > K}$
- $V(T, S_T)=max(S_T-K, 0)$

##  2. Diferenças Finitas

Abaixo, apresentarei os elementos necessários para implementar o método diferenças finitas, sendo eles: a [diferenciação](https://pt.wikipedia.org/wiki/Diferenciação_numérica) das derivadas parciais necessárias usando o Grid; a discretização da condição final para cada derivativo; e suas respectivas condições de contorno (nos limites do grid).

### 2.1. Diferenciação no Grid

De acordo com Wilmott, o grid utilizado pelo método de diferenças finitas tem passos de tempo e de preço (ou log do preço) geralmente homogênios. Porém, **não há** restrição para a forma do Grid, desde que os ajustes necessários sejam feitos. Como exposto por

Considerando que podemos discretizar $t$ como $t=T-k\delta t$ e do $S$ como $S = i\delta S$, onde $i$ e $k$ são seus respectivos passos no grid, podemos escrever o valor da opção em cada ponto do grig como sendo:

$$V^{k}_{i} = ( i \delta S, T-k \delta t )$$

Seguindo notas de aula, aplicando Taylor ao preço $V(T, S_T)$ de um derivativo genérico, posso escrever a seguinte equação diferencial parabólica:

$$\frac{\partial V}{\partial t} + a(S, t) \cdot \frac{\partial V^2}{\partial S^2} + b(S, t) \cdot \frac{\partial V}{\partial S} + c(S, t)\cdot V = 0 $$

Sendo que $\frac{\partial V}{\partial t}$ também é chamado de theta ($\theta$), $\frac{\partial V}{\partial S}$ de delta ($\Delta$) e $\frac{\partial V^2}{\partial S^2}$ de gamma ($\Gamma$). Como demostrado por Wilmott, como a definição da primeira derivada $V$ em relação a $t$ é dado por

$$\frac{\partial V}{\partial t} = \underset{h \, \rightarrow \, 0}{\lim}\, \frac{V(S, t + h) - V(S, t)}{h}$$

Então podemos aproximar $\theta$ como sendo $\frac{\partial V}{\partial t} (S, t) \approx \frac{V_{i}^{k} - V_{i}^{k+1} }{\delta t}$. Note que $i$, que é relacionado ao passo do ativo, ficou fixo. *Também estou ignorando os erros de aproximação, como ignorarei em todas as outras diferenciações*.

O mesmo raciocínio pode ser utilizado para aproximar o $\Delta$. Porém, Wilmott ainda sugere que se utilize a [diferença centrada](http://math.stackexchange.com/questions/888259/can-someone-explain-in-general-what-a-central-difference-formula-is-and-what-it), que é dada por $\frac{\partial V}{\partial S} (S, t) \approx \frac{V_{i+1}^{k} - V_{i-1}^{k} }{2 \delta S}$. A discretização anterior também poderia ser utilizada, mas esta oferece um erro de aproximação menor. O único problema desta abordagem é que preciso saber os valores $S + \delta S$ e $S - \delta S$ e nas fronteiras do Grid não terei esta informação. Por tanto, nestes casos utilizarei a discretrização utilizando apenas um dos lados.

Por fim, para achar o $\gamma$, Wilmott subtrai o delta forward do delta backward e divide o resultado por $\delta S$, chegando na aproximação  $\frac{\partial V^2}{\partial S^2} (S, t) \approx \frac{V_{i+1}^{k} - 2 V_{i}^{k} + V_{i-1}^{k} }{\delta S^2}$. Como demonstrado em notas de aula, todos estes resultados podem ser checados aplicando a expansão de Taylor.

### 2.2. Condição Terminal e Payoffs

O valor da opção no vencimento é simplemente seu payoff. Assim, não é necessário resolver nada para $T$, apenas para $S$. Usando a notação de diferenças finitas, os payoffs desejados ficam sendo da forma:

- $V_{i}^{0}=ln(i\delta S)$
- $V_{i}^{0}=(ln(i\delta S))^2$
- $V_{i}^{0}=(i\delta S-K)^2$
- $V_{i}^{0}=\mathbf{1}_{i\delta S > K}$
- $V_{i}^{0}=max(i\delta S-K, 0)$

Como o Wilmott explica, o método de diferenças finitas começa de trás para frente, será destes valores que a iteração começará, como se estivéssemos calculando o preço de um derivativo por árvores binomiais, também de trás para frente.

### 2.3. Condições de Contorno

Quando estivermos percorrendo o Grid, necessitaremos definir o preço do derivativo em seus extremos, quando $S=0$ e $S=I \delta S$, onde $I$ é o ponto mais alto do Grid. Esta condição depende do instrumento que está sendo precificado. Para **todos** dos contratos, utilizaremos a seguinte condição para quando $S=0$:

$$V^{k}_{0} = (1 - r \delta t)V^{k-1}_{0}$$

Para a condição de contorno superior, utilizaremos para a **maioria dos contratos**:

$$V^{k}_{I} = 2V^{k}_{I-1} - V^{k}_{I-2}$$

Esta condição é adequada pois, a medida que $S \rightarrow \infty$, o $\Gamma$ da maioria dos contratos tende a zero. Porém, isso não é verdade para o contrato cujo payoff é descrito por $V_{i}^{0}=(i\delta S-K)^2$. Diferenciando novamente o [delta](https://nbviewer.jupyter.org/github/ucaiado/Replicating_Strategy/blob/master/UiraCaiadoEx01.ipynb) deste contrato, chegamos que o gamma será dado por $2 \cdot e^{(r + \sigma^2)(T-t)}$. Neste caso,
partindo da equação discretizada do $\Gamma$ e resolvendo para $V^{k}_{I}$, tenho que:

\begin{equation}
\begin{aligned}
\Gamma &= \frac{V_{I+1}^{k} - 2 V_{I}^{k} + V_{I-1}^{k} }{\delta S^2}\\
2 \cdot e^{(r + \sigma^2)(\delta t)} &= \frac{V_{I+1}^{k} - 2 V_{I}^{k} + V_{I-1}^{k} }{\delta S^2}\\
V_{I}^{k} &= 2 \delta S^2 e^{(r + \sigma^2)(\delta t)} + 2V_{I-1}^{k} - V_{I-2}^{k}\\
V_{I}^{k} &\approx 2 \delta S^2 + 2V_{I-1}^{k} - V_{I-2}^{k}\\
\end{aligned}
\end{equation}

## 3. Implementando o Modelo

Nesta seção vou decsrever o método de diferenças finitas explícito e implementar os códigos necessários.

### 3.1. Método Explícito

Seguindo notas de aula, substituindo as diferenciações encontradas na equação parabólica mencionada anteriormente e reescrevendo os outros termos com a notação de diferenças finitas, temos que:

\begin{equation}
\begin{aligned}
\frac{\partial V}{\partial t} + a(S, t) \cdot \frac{\partial V^2}{\partial S^2} + b(S, t) \cdot \frac{\partial V}{\partial S} + c(S, t)\cdot V &= 0 \\
\frac{V_{i}^{k} - V_{i}^{k+1} }{\delta t} + a_{i}^{k} \cdot \frac{V_{i+1}^{k} - 2 V_{i}^{k} + V_{i-1}^{k} }{\delta S^2} + \\b_{i}^{k} \cdot \frac{V_{i+1}^{k} - V_{i-1}^{k} }{2 \delta S} + c_{i}^{k}\cdot V_{i}^{k} &= 0
\end{aligned}
\end{equation}

Rearranjando equação acima para isolar $V_{i}^{k+1}$ e renomeando alguns termos, ficamos com:

$$V_{i}^{k+1} = A_{i}^{k}V_{i-1}^{k} + \left( 1 + B_{i}^{k} \right)V_{i}^{k} + C_{i}^{k}V_{i+1}^{k}$$

Onde:

\begin{equation}
\begin{aligned}
A_{i}^{k} &= \nu_1 a_{i}^{k} - 0.5\nu_2 b_{i}^{k} \\
B_{i}^{k} &= -2\nu_1 a_{i}^{k} - \delta t c_{i}^{k} \\
C_{i}^{k} &= \nu_1 a_{i}^{k} + 0.5\nu_2 b_{i}^{k}
\end{aligned}
\end{equation}

Sendo que $\nu_1=\frac{\delta t}{\delta S ^2}$ e $\nu_2=\frac{\delta t}{\delta S}$. A equação acima está definida apenas entre $i=1, ..., I-1$. OS pontos restantes necessários para discretização vem das condições de contorno. Como nós conhecemos o valor terminal em $V_{i}^{0}$, podemos calcular o valor de $V_{i}^{1}$ e assim por diante. Como o valor do instrumento em $k+1$ só depende dos valores dele em $k$, chamamos este método de **método explícito**.

### 3.2. Outros Detalhes

#### a. Estabilidade

Para que a solução deste método seja estável, é necessário que satisfaça $\delta t \, \leqslant \frac{\delta S^2}{2\sigma^2 S^2}$ e $\delta S \leqslant \frac{2a}{|b|}$. Vou utilizar a primeira delas explicitamente na implementação, porém vou fazer a seguinte transformação:

$$\delta t \, \leqslant \frac{\delta S^2}{2\sigma^2 S^2} = \frac{1}{\sigma^2} \left(\frac{\delta S}{S}\right)^2 = \frac{1}{\sigma^2 I^2}$$

Onde $I = \delta  S \times S$.

#### b. Interpolação Bilinear

Devido a discretização do modelo, pode ser que seja necessário obter um valor que esteja entre os nós do Grid. Nestes casos, adotarei uma interpolação de duas dimensões (Preço e tempo) chamada [interpolação bilinear](https://en.wikipedia.org/wiki/Bilinear_interpolation). Ela consiste em realizar uma interpolação linear primeiro em uma direção e depois na outra. Considerando um quadrante específico do Grid, em que há quatro preços de opção, basicamente se divide o quadrante em quatro (onde os pontos de divisão são os valores desejados) e se utiliza a área de cada um deles como peso para os valores das opções. Assim, terminamos com a seguinte fórmula:

$$\frac{\sum_{i=1}^{4} A_i V_i}{\sum_{i=1}^{4} A_i}$$

Onde $A$ é a área dos retângulos e $V$ o preço da opção em cada canto do quadrante principal. Cada $A_i$ é oposto ao $V_i$ correspondente (o $A_2$ é oposto ao $V_2$, por exemplo).

### 3.3. Criando o Grid

Primeiro, vou definir as classes para a criação do Grid. Como quero acessar os valores posteriormente, vou manter todos em uma estrutura, ainda que este provavelmente não seja o método mais adequado pensando em velocidade.

```python
import finite_difference; reload(finite_difference);
x = finite_difference.Grid(f_vol=0.5,
                           f_value=100.,
                           f_time=1.,
                           i_nas=10,
                           i_nts=10)
print x
```

           0     1     2     3     4     5     6     7     8     9
    0    0,0   0,1   0,2   0,3   0,4   0,5   0,6   0,7   0,8   0,9
    1    1,0   1,1   1,2   1,3   1,4   1,5   1,6   1,7   1,8   1,9
    2    2,0   2,1   2,2   2,3   2,4   2,5   2,6   2,7   2,8   2,9
    3    3,0   3,1   3,2   3,3   3,4   3,5   3,6   3,7   3,8   3,9
    4    4,0   4,1   4,2   4,3   4,4   4,5   4,6   4,7   4,8   4,9
    5    5,0   5,1   5,2   5,3   5,4   5,5   5,6   5,7   5,8   5,9
    6    6,0   6,1   6,2   6,3   6,4   6,5   6,6   6,7   6,8   6,9
    7    7,0   7,1   7,2   7,3   7,4   7,5   7,6   7,7   7,8   7,9
    8    8,0   8,1   8,2   8,3   8,4   8,5   8,6   8,7   8,8   8,9
    9    9,0   9,1   9,2   9,3   9,4   9,5   9,6   9,7   9,8   9,9
    10  10,0  10,1  10,2  10,3  10,4  10,5  10,6  10,7  10,8  10,9

```python
%timeit x = Grid(f_vol=0.2, f_value=100., f_time=1., i_nas=100)
```

    1000 loops, best of 3: 344 µs per loop

Como comentei, não é uma estrtutura eficiente. Demorou quase 5 segundos para criar uma estrtutura de 10.000 nós. Porém acredito que isso me ajudará adiante.

### 3.3. Testando Método

Seguindo implementação do livro, também há outra opção para se encontrar $V_{i}^{k+1}$. Substituindo a notação da fórmula de precificação e assumindo que possuimos os valores para $\Delta$, $\Gamma$ e $V_{i}^{k}$, podemos isolar o $\theta$, ficando com

\begin{equation}
\begin{aligned}
\theta + a_{i}^{k} \cdot \Gamma + b_{i}^{k} \cdot \Delta + c_{i}^{k}\cdot V_{i}^{k} &= 0\\
-a_{i}^{k} \cdot \Gamma - b_{i}^{k} \cdot \Delta - c_{i}^{k}\cdot V_{i}^{k} &= \theta
\end{aligned}
\end{equation}

Assim, para uma opção que não paga dividendos, que o ativo base segue um movimento brawniano geométrico, e esta sendo avaliado no mundo neutro a risco, temos que o parâmetro $c=-r$, $b=rS$ e $a=0.5 \cdot \sigma^2 S^2$. Assim, fico com o theta sendo da forma:

$$\theta = r V_{i}^{k} - rS_{i}^{k}\Delta - 0.5\sigma^2 S^2\Gamma$$

Então, o preço do derivativo em $k+1$ pode ser dado por:

$$V_{i}^{k+1} = V_{i}^{k} - \delta t \theta$$

Para comparar os resultados obtidos, também serão calculados os preços e as gregas de cada opção analiticamente, seguindo as equações de preficição demostradas em [trabalho](https://nbviewer.jupyter.org/github/ucaiado/Replicating_Strategy/blob/master/UiraCaiadoEx01.ipynb) anterior. Abaixo vou imprimir os resultados obtidos para uma Call Européia para comparação com exemplos do Wilmott.

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.2, # desvio padra do ativo objeto
           "f_time": 1.,  # tempo para vencimento em anos
           "f_r": 0.05,  # taxa de juros anual
           "i_nas": 20,  # passos que o ativo sera discretizado
           "i_nts": 10,  # passos que o tempo sera discretizado (opcional)
           "f_K": 100.  # strike da opcao
          }

%time self = finite_difference.EuropianCall(**d_param)
```

    Wall time: 582 ms

```python
# imprime apenas a parte da matriz demonstrada no exemplo do livro, pag 1216, tabela 77.1
df = self.df_opt_prices.copy()
df.columns = ["{:.3f}".format(x) for x in df.columns]
df.tail(15).head(9)
```

      0.000
      0.111
      0.222
      0.333
      0.444
      0.556
      0.667
      0.778
      0.889
      1.000

      60.0
      0.0
      0.000000
      0.000000
      0.000000
      0.000000
      0.001046
      0.004769
      0.012912
      0.027040
      0.048408

      70.0
      0.0
      0.000000
      0.000000
      0.000000
      0.010816
      0.040306
      0.092362
      0.168004
      0.266547
      0.386414

      80.0
      0.0
      0.000000
      0.000000
      0.084278
      0.248619
      0.475041
      0.746832
      1.051237
      1.379044
      1.723707

      90.0
      0.0
      0.000000
      0.512500
      1.148000
      1.807437
      2.461115
      3.100759
      3.724696
      4.333380
      4.927851

      100.0
      0.0
      2.500000
      4.013889
      5.200154
      6.223712
      7.149693
      8.008182
      8.816223
      9.584443
      10.320129

      110.0
      10.0
      10.555556
      11.571451
      12.561620
      13.500820
      14.389205
      15.235188
      16.045095
      16.824334
      17.576942

      120.0
      20.0
      20.555556
      21.108025
      21.790273
      22.510062
      23.243401
      23.974649
      24.698653
      25.412183
      26.114374

      130.0
      30.0
      30.555556
      31.108025
      31.657425
      32.248867
      32.862026
      33.491183
      34.127231
      34.766578
      35.405282

      140.0
      40.0
      40.555556
      41.108025
      41.657425
      42.203772
      42.764972
      43.335178
      43.915298
      44.500544
      45.090213

O título das colunas se refere ao tempo, começando no vencimento (tempo zero) até $t$ (o dia em que a opção está sendo avaliada). As linhas se referem ao preço do ativo objeto. *Note que começa a aparecer uma diferença em relação ao livro quando o ativo está valendo $R\$ 110.00$  ou menos. Como o método utiliza os três nós subjacentes anteriores para calcular o valor do nó posterior, o nó $(\,90.0 \,; 0.111)$ não deveria apresentar valor algum de fato. Porém, no exemplo do Wilmott, apresentou.*

## 4. Precificando Instrumentos

Nesta seção vou precificar alguns instrumentos e comparar com suas soluções analíticas.

### 4.1. Call Européia

Partindo de implementação anterior, apenas resta imeplementar o gamma analítico da equação, que é dado abaixo:
$$\frac{\partial V^2}{\partial S^2} = \frac{\Delta}{\partial S} = \frac{N'(d1)}{S \sigma \sqrt{t}} $$

Abaixo vou comparar os resultados analíticos com o que obtive do método de diferenças finitas.

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.5, # desvio padra do ativo objeto
           "f_time": .5,  # tempo para vencimento em anos
           "f_r": 0.10,  # taxa de juros anual
           "i_nas": 20,  # passos que o ativo sera discretizado
           "f_K": 100.  # strike da opcao
          }

%time my_option = finite_difference.EuropianCall(**d_param)
```

    CPU times: user 2.24 s, sys: 23.1 ms, total: 2.26 s
    Wall time: 2.28 s

```python
# plotando graficos do inicio do periodo
l_prices = np.arange(50., 151., 1.)
my_option.compare_to_analytical_solutions(l_prices, d_param['f_time'])
```

### 4.2. Contrato Log

Derivando gamma analítico da equação a paritr do Delta $\Delta = \frac{e^{-r\cdot t}}{S}$, tenho que:

\begin{equation}
\begin{aligned}
\frac{\partial V^2}{\partial S^2} &= \frac{\Delta}{\partial S} = -\frac{e^{-r\cdot t}}{S^2}
\end{aligned}
\end{equation}

Abaixo vou comparar os resultados analíticos com o que obtive do método de diferenças finitas. A única diferença que é necessária tomar nesta implementação é quando o valor do ativo no final é $0$. Nestes casos, fiz o valor da opção também ser $0$.

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.5, # desvio padra do ativo objeto
           "f_time": .5,  # tempo para vencimento em anos
           "f_r": 0.10,  # taxa de juros anual
           "i_nas": 20,  # passos que o ativo sera discretizado
           "f_K": 100.  # strike da opcao
          }

%time my_option = finite_difference.LogContract(**d_param)
```

    CPU times: user 1.83 s, sys: 11.1 ms, total: 1.84 s
    Wall time: 1.84 s

```python
# plotando graficos do inicio do periodo
l_prices = np.arange(50., 151., 1.)
my_option.compare_to_analytical_solutions(l_prices, d_param['f_time'])
```

### 4.3. Contrato Log Quadrático

Derivando gamma analítico da equação e aplicando a regra de quociente, tenho que, se
$$\Delta = \frac{2e^{-r\cdot t}}{S}\left[ln(S)+ \left(r - \frac{\sigma^2}{2} \right) \cdot t \right]$$

Então

\begin{equation}
\begin{aligned}
\frac{\partial V^2}{\partial S^2} &= \frac{\Delta}{\partial S}= 2e^{-r\cdot t} \frac{\partial}{\partial S} \left [ \frac{ln(S)+ t \left(r - \frac{\sigma^2}{2} \right)}{S} \right ]\\
&= 2e^{-r\cdot t} \frac{S \frac{\partial}{\partial S} \left ( ln(S)+ t \left(r - \frac{\sigma^2}{2} \right) \right) - \left( ln(S)+ t \left(r - \frac{\sigma^2}{2} \right) \right) \frac{\partial}{\partial S} (S)}{S^2}\\
&= 2e^{-r\cdot t} \frac{S \cdot \frac{1}{S} - ln(S)- tr + \frac{t \sigma^2}{2}}{S^2}\\
&= \frac{e^{-r t}}{S^2}\left[2 + \sigma^2 t - 2ln(S) -2rt\right]
\end{aligned}
\end{equation}

Abaixo vou comparar os resultados analíticos com o que obtive do método de diferenças finitas.

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.5, # desvio padra do ativo objeto
           "f_time": .5,  # tempo para vencimento em anos
           "f_r": 0.10,  # taxa de juros anual
           "i_nas": 20,  # passos que o ativo sera discretizado
           "f_K": 100.  # strike da opcao
          }

%time my_option = finite_difference.SquaredLogContract(**d_param)
```

    CPU times: user 2.02 s, sys: 85.9 ms, total: 2.11 s
    Wall time: 2.09 s

```python
# plotando graficos do inicio do periodo
l_prices = np.arange(50., 151., 1.)
my_option.compare_to_analytical_solutions(l_prices, d_param['f_time'])
```

### 4.4. Exótico Quadrático

Derivando gamma analítico da equação a partir de $\Delta = 2S e^{(r + \sigma^2)} - 2K$, tenho que:

\begin{equation}
\begin{aligned}
\frac{\partial V^2}{\partial S^2} &= 2 e^{(r + \sigma^2)}
\end{aligned}
\end{equation}

Neste caso, também preciso **mudar as condições de contorno**. Abaixo vou comparar os resultados analíticos com o que obtive do método de diferenças finitas.

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.5, # desvio padra do ativo objeto
           "f_time": .5,  # tempo para vencimento em anos
           "f_r": 0.10,  # taxa de juros anual
           "i_nas": 20,  # passos que o ativo sera discretizado
           "f_K": 100.  # strike da opcao
          }

%time my_option = finite_difference.SquaredExotic(**d_param)
```

    CPU times: user 1.82 s, sys: 34.3 ms, total: 1.86 s
    Wall time: 1.85 s

```python
# plotando graficos do inicio do periodo
l_prices = np.arange(50., 151., 1.)
my_option.compare_to_analytical_solutions(l_prices, d_param['f_time'])
```

### 4.5. Opção Digital

Por último, vou compara o resultado do método explícito com o resultado analítico de uma opção digital. Seguindo a derivação do gamma analítico a [daqui](http://www.stat.nus.edu.sg/~stalimtw/MFE5010/PDF/L2digital.pdf), tenho que:

\begin{equation}
\begin{aligned}
\frac{\partial V^2}{\partial S^2} &= - \frac{e^{-rt} d_1 N(d_2) }{S^2 \sigma^2 t }
\end{aligned}
\end{equation}

Abaixo vou plotar os resultados para comparação

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.5, # desvio padra do ativo objeto
           "f_time": .5,  # tempo para vencimento em anos
           "f_r": 0.10,  # taxa de juros anual
           "i_nas": 20,  # passos que o ativo sera discretizado
           "f_K": 100.  # strike da opcao
          }

%time my_option = finite_difference.DigitalOption(**d_param)
```

    CPU times: user 2.46 s, sys: 92.1 ms, total: 2.55 s
    Wall time: 2.61 s

```python
# plotando graficos do inicio do periodo
# plotando graficos do inicio do periodo
l_prices = np.arange(50., 151., 1.)
my_option.compare_to_analytical_solutions(l_prices, d_param['f_time'])
```

A solução não aderiu muito bem às soluções analíticas. Vou tentar melhorar a quantidade de discretizaçòes do ativo subjacente para tentar melhorar este resultado

```python
import finite_difference; reload(finite_difference);
d_param = {"f_St": 100.,  # preco do ativo
           "f_sigma": 0.5, # desvio padra do ativo objeto
           "f_time": .5,  # tempo para vencimento em anos
           "f_r": 0.10,  # taxa de juros anual
           "i_nas": 100,  # passos que o ativo sera discretizado
           "f_K": 100.  # strike da opcao
          }

%time my_option = finite_difference.DigitalOption(**d_param)
```

    CPU times: user 4min 26s, sys: 1.62 s, total: 4min 28s
    Wall time: 4min 32s

```python
# plotando graficos do inicio do periodo
# plotando graficos do inicio do periodo
l_prices = np.arange(50., 151., 1.)
my_option.compare_to_analytical_solutions(l_prices, d_param['f_time'])
```

Curiosamente, a melhorar foi marginal. A implementação que fiz também se mostrou inadequada, uma vez que quando criei um grid com $100$ passos no ativo, demorou 4 minutos e meio para realizar todos os cálculos.

## 5. Conclusão

Neste trabalho o método de diferenças finitas explícito foi comparado à precificação de instrumentos com solução analítica. De maneira geral, o método conseguiu aproximar bem o preço das opções e se mostrou bastante flexível, precisando de poucos ajustes para precificar cada instrumento. Apesar de não testado aqui, provavelmente a inclusão de não linearidades nos contratos também não exigiria muitos ajustes, como a utilização de parâmetros incertos, ou mesmo exercício antecipado.

## 6. Últimas Considerações

A decisão de usar classes no lugar de um array dificultou a implementação. A aplicação do método criando um objeto para cada nó do Grid também se mostrou muito lenta. Porém, foi interessante notar que o simples aumento da discretização do grid não melhorou tanto a aderência aos valores calculados pelas soluções analíticas. Talvez a mudança do formato do grid mostrasse melhores resultados.

_Style notebook and change matplotlib defaults_

```python
#loading style sheet
from IPython.core.display import HTML
HTML(open('ipython_style.css').read())
```

```python
#changing matplotlib defaults
%matplotlib inline
import seaborn as sns
sns.set_palette("deep", desat=.6)
sns.set_context(rc={"figure.figsize": (8, 4)})
sns.set_style("whitegrid")
sns.set_palette(sns.color_palette("Set2", 10))
```

```python

```