```python
# HIDDEN
from datascience import *
from prob140 import *
import numpy as np
import matplotlib.pyplot as plt
plt.style.use('fivethirtyeight')
%matplotlib inline
```

## Expectation by Conditioning ##

Let $T$ be a random variable, and let $S$ be a random variable defined on the same space as $T$. As we have seen, conditioning on $S$ might be a good way to find probabilities for $T$ if $S$ and $T$ are related. In this section we will see that conditioning on $S$ can also be a good way to find the expectation of $T$.

We will start with a simple example to illustrate the ideas. Let the joint distribution of $T$ and $S$ be as in the table below.

```python
t = [3, 4]
s = [5, 6, 7]
pp = [0.1, 0.2, 0.3, 0.1, 0.2, 0.1]
jd2 = Table().values('T', t, 'S', s).probability(pp)
jt_dist = jd2.to_joint()
jt_dist
```

      T=3
      T=4

      S=7
      0.3
      0.1

      S=6
      0.2
      0.2

      S=5
      0.1
      0.1

How can $S$ be involved in the calculation of $E(T)$?

Notice that to find $E(T)$, you could use the joint distribution table and the definition of expectation as follows:

```python
3*(0.3 + 0.2 + 0.1) + 4*(0.1 + 0.2 + 0.1)
```

    3.4

This is equivalent to going to each cell of the table, weighting the value of $T$ in that cell with the probability in the cell, and then adding. Here's another way of looking at this.

Let's condition on $S$:

```python
jt_dist.conditional_dist('T', 'S')
```

      T=3
      T=4
      Sum

      Dist. of T | S=7
      0.75
      0.25
      1.0

      Dist. of T | S=6
      0.50
      0.50
      1.0

      Dist. of T | S=5
      0.50
      0.50
      1.0

      Marginal of T
      0.60
      0.40
      1.0

Each of the three conditional distributions is a distribution in its own right. Therefore its histogram has a balance point, just as the marginal distribution of $T$ does.

```python
jt_dist.conditional_dist('T', 'S', show_ev=True)
```

      T=3
      T=4
      Sum
      EV

      Dist. of T | S=7
      0.75
      0.25
      1.0
      3.25

      Dist. of T | S=6
      0.50
      0.50
      1.0
      3.50

      Dist. of T | S=5
      0.50
      0.50
      1.0
      3.50

      Marginal of T
      0.60
      0.40
      1.0
      3.40

You can see $E(T) = 3.4$ in the row corresponding to the distribution of $T$. And you can also see the *conditional expectation of $T$* given each possible value of $S$:
- $~E(T \mid S=5) = 3.5$
- $~E(T \mid S=6) = 3.5$
- $~E(T \mid S=7) = 3.25$

This defines a *function of $S$*: for each value $s$ of $S$, the function returns $E(T \mid S=s)$.

```python
ev_T_given_S = Table().with_column(
    's', s,
    'E(T | S = s)', [3.5, 3.5, 3.25],
    'P(S = s)', [0.2, 0.4, 0.4]
)
ev_T_given_S
```

            s	 E(T | S = s)	 P(S = s)

            5   	 3.5         	 0.2

            6   	 3.5         	 0.4

            7   	 3.25        	 0.4

This function of $S$ is called the *conditional expectation of $T$ given $S$* and is denoted $E(T \mid S)$. Unlike expectation which is a number, conditional expectation is a random variable.

As it's a random variable, it has an expectation, which we can calculate using the non-linear function rule. The answer is a quantity that you will recognize.

```python
ev = sum(ev_T_given_S.column('E(T | S = s)')*ev_T_given_S.column('P(S = s)'))
ev
```

    3.4000000000000004

That's right: it's the expectation of $T$.

What we have learned from this is that $E(T)$ is the *average of the conditional expectations of $T$ given the different values of $S$, weighted by the probabilities of those values*.

In short, $E(T)$ is the *expectation of the conditional expectation of $T$ given $S$*.

### Conditional Expectation as a Random Variable ###
In general, suppose $T$ and $S$ are two random variables on a probability space.

Then for each fixed value of $s$, $T$ has a conditional distribution given $S=s$. This is an ordinary distribution and has an expectation. That is called the *conditional expectation of $T$ given $S=s$* and is denoted $E(T \mid S = s)$.

So for each $s$, there is a value $E(T \mid S=s)$. This defines a function of the random variable $S$. It is called the *conditional expectation of $T$ given $S$*, and is denoted $E(T \mid S)$.

The key difference between expectation and conditional expectation:

- $E(T)$, the expectation of $T$, is a real number.
- $E(T \mid S)$, the conditional expectation of $T$ given $S$, is a function of $S$ and hence is a random variable.

Since $E(T \mid S)$ is a random variable, it has an expectation. That expectation is equal to $E(T)$. We observed this in an example; now here is a proof.

### Iterated Expectations ###
Suppose we want the expectation of a random variable, and suppose it is easy for us to say what that expectation would be if we were given the value of a related random variable. The rule of *iterated expectations* says that we can find that conditional expectation first, and take its expectation to get our answer.

Formally, let $S$ and $T$ be two random variables on the same space. Then $E(T) = E(E(T \mid S))$.

Proof:

$$
\begin{align*}
E(T) &= \sum_{\text{all }t} tP(T=t) \\ \\
&= \sum_{\text{all }t} t \sum_{\text{all }s} P(S=s, T=t) \\ \\
&= \sum_{\text{all }t} t \sum_{\text{all }s} P(S=s)P(T=t \mid S=s) \\ \\
&= \sum_{\text{all }s} \Big{(} \sum_{\text{all }t} tP(T=t \mid S=s) \Big{)} P(S=s) \\ \\
&= \sum_{\text{all }s} E(T \mid S=s)P(S=s) \\ \\
&= E(E(T \mid S))
\end{align*}
$$

### Random Sums ###
Let $X_1, X_2, \ldots $ be i.i.d. and let $E(X_1) = \mu_X$. Let $N$ be a non-negative integer valued random variable that is independent of the sequence of $X$'s and let $E(N) = \mu_N$.

Define the *random sum* $S$ to be

$$
S = X_1 + X_2 + \ldots + X_N
$$

where $S = 0$ if $N=0$.

Notice that $S$ is the sum of a random number of terms.

**Question.** What is $E(S)$?

**Answer.** If $N$ were the constant 10, then the answer would be $10\mu_X$. This is our signal to condition on $N$. Here are the steps to follow.
- First condition on a fixed value of $N$. Given $N=n$, $S$ is the sum of $n$ i.i.d. terms. Hence

$$
E(S \mid N=n) = n\mu_X
$$

This is an equality of real numbers. Note that it is true for all $n$, including 0.
- Next write the conditional expectation in random variable notation.

$$
E(S \mid N) = N\mu_X
$$

This is an equality of random variables.
- Now use iterated expectations.

$$
E(S) = E(E(S \mid N)) = E(N\mu_X) = E(N)\mu_X = \mu_N\mu_X
$$

This is a natural answer. It is the expected number of terms being added times the expected size of each of those terms.

This is an important point to note about calculating expectations by conditioning. The natural answer is often correct.

### Population Size in a Branching Process ###
In a *Galton-Watson branching process*, each individual has a random number of progeny. Assume that the numbers of progeny of the different indviduals are i.i.d. with mean $\mu$. Suppose the process starts with one individual in Generation 0.

**Question.** Assuming that there are no deaths, what is the expected total number of individuals in Generations 0 through $n$?

**Answer.** Let $T_k$ be the number of individuals born in Generation $k$. We are assuming $T_0 = 1$. By the example above, for each $k > 1$,

$$
E(T_k) = E(T_{k-1})\mu
$$

So by induction, for each $k > 1$ the expected number of people in Generation $k$ is

$$
E(T_k) = \mu^k
$$

Indeed, the result is true for $k=0$ as well. So the expected total number of people through Generation $n$ is

$$
\begin{equation}
\sum_{k=0}^n \mu^k =
 \begin{cases}
      n & \text{if } \mu = 1 \\
      \frac{1 - \mu^{n+1}}{1 - \mu} = \frac{\mu^{n+1} - 1}{\mu - 1} & \text{if } \mu \ne 1
   \end{cases}
\end{equation}
$$

The value of $\mu$, the expected number of progeny of a single individual, determines how this expected total behaves as $n$ gets large. Even with no deaths, if $\mu < 1$ the expected population size tends to a positive constant as $n \to \infty$. But if $\mu \ge 1$ then the expected population size explodes.

### Other Properties of Conditional Expectation ###
The most important property of conditional expectation is the iteration that we have studied in this section. But conditional expectation has other properties that are analogous to those of expectation. **They are now expressed as equalities of random variables instead of equalities of real numbers.**

Go through the list and notice that all the moves you'd naturally want to make are justified. The proofs are routine; we won't go through them.
- **Additivity.** $~E(T+U \mid S) = E(T \mid S) + E(U \mid S)$
- **Linear Transformation.** $~E(aT+b \mid S) = aE(T \mid S) + b$

Two more properties formalize the idea that the variable that is given can be treated as a constant in conditional expectations.
- **"Constant"**: Let $g$ be a function. Then $E(g(S) \mid S) = g(S)$.
- **"Pulling out a Constant"**: $~E(g(S)T \mid S) = g(S)E(T \mid S)$.

For example,

$$
E(3ST + \log(S)U + 7 \mid S) = 3SE(T \mid S) + \log(S)E(U \mid S) + 7
$$

though we sincerely hope you won't encounter a random variable as bizarre as this.

```python

```


```python
# Neural network; OOP and functional approach

import numpy as np
import matplotlib.pyplot as plt
import sympy as sym
from sympy import *
import math
```

```python
inputs = np.array([[0, 1, 0],
                   [0, 1, 1],
                   [0, 0, 0],
                   [1, 0, 0],
                   [1, 1, 1],
                   [1, 0, 1]])

outputs = np.array([[0], [0], [0], [1], [1], [1]])
```

```python
class NeuralNetwork:

    def __init__(self, inputs, outputs):
        self.inputs  = inputs
        self.outputs = outputs
        self.weights = np.array([[.50], [.50], [.50]])
        self.error_history = []
        self.epoch_list = []
```

```python
# define function, sigmoid, logistic
def sigmoid(self, x, deriv=False):
    if deriv == True:
        return x * (1 - x)
    return 1 / (1 + np.exp(-x))

#sym.diff(1/1 + sym.exp(-x))
```

```python
# outputs hidden layer guess, starts with sum of feature * weight. input * weight matrix * sigmoid function = probabilty btw 0-1
# X (input matrix) * w (Weights) = sum(xw) = sum(xw)
def feed_forward(self):
    self.hidden = self.sigmoid(np.dot(self.inputs, self.weights))
```

```python
#functional approach
X = np.array([[0,0,1], [0,1,0], [1,0,1]])
ouputs = [0,0,1,1]

def neural_net (inputs, outputs):
    inputs = inputs
    outputs = outputs

def sigmoidftn(x):
    return 1 / (1 + np.exp(-x))
def sigmoidder1(x):
    return x * (1 - x)

# def sigmoidder2(x):
#     x = sym.Symbol('x')
#     f = sym.diff(1 / (1 + sym.exp(-x)))
#     der_of_sigmoid = lambdify(x, f(x))
#     return der_of_sigmoid(x)

X = np.array([[0,0,1], [0,1,0], [1,0,1], [1,1,1]]) # input data
w = [0.5, 0.5, 0.5] # arbitrary weights set

# X (input matrix(feature columns)) * w (Weights) = sum(xw)
d = np.dot(X, w) # dot product of input and weights

#one training iteration, prediction of hidden layer
hidden = sigmoidftn(d)

#calculate error of hidden layer's prediction
error = outputs - hidden
delta = error * sigmoidder(hidden)

#error *
```

```python
sigmoidder1(hidden)
```

    array([0.23500371, 0.23500371, 0.19661193, 0.14914645])

```python
x = sym.Symbol('x')
der_of_sigmoid = sym.diff(1 / (1 + sym.exp(-x))) # derivative of sigmoid function

sigder = lambdify(x, der_of_sigmoid)
#error * dersigmoids
der_of_sigmoid
```

$\displaystyle \frac{e^{- x}}{\left(1 + e^{- x}\right)^{2}}$

```python
# go through layers of neural net, indicate weight contributions to output/error, change weight based on gradient of hidden layers output.
def backpropagation(self):
    self.error  = self.outputs - self.hidden
    delta = self.error * self.sigmoid(self.hidden, deriv=True)
    self.weights += np.dot(self.inputs.T, delta)
```

```python
def train(self, epochs=25000):
    for epoch in range(epochs):
        self.feed_forward()
        self.backpropagation()

        self.error_history.append(np.average(np.abs(self.error)))
        self.epoch_list.append(epoch)
```

```python
def predict(self, new_input):
    prediction = self.sigmoid(np.dot(new_input, self.weights))
    return prediction
```

```python
NN = NeuralNetwork(inputs, outputs)
```

```python

```

```python
class NeuralNetwork:

    def __init__(self, inputs, outputs):
        self.inputs  = inputs
        self.outputs = outputs
        self.weights = np.array([[.50], [.50], [.50]])
        self.error_history = []
        self.epoch_list = []

    def sigmoid(self, x, deriv=False):
        if deriv == True:
            return x * (1 - x)
        return 1 / (1 + np.exp(-x))

    def feed_forward(self):
        self.hidden = self.sigmoid(np.dot(self.inputs, self.weights))

    def backpropagation(self):
        self.error  = self.outputs - self.hidden
        delta = self.error * self.sigmoid(self.hidden, deriv=True)
        self.weights += np.dot(self.inputs.T, delta)

    def train(self, epochs=25000):
        for epoch in range(epochs):
            self.feed_forward()
            self.backpropagation()

            self.error_history.append(np.average(np.abs(self.error)))
            self.epoch_list.append(epoch)

    def predict(self, new_input):
        prediction = self.sigmoid(np.dot(new_input, self.weights))
        return prediction
```

```python
NN = NeuralNetwork(inputs, outputs)
```

```python
NN.train()
```

```python
example = np.array([[1, 1, 0]])
example_2 = np.array([[0, 1, 1]])

print(NN.predict(example), ' - Correct: ', example[0][0])
print(NN.predict(example_2), ' - Correct: ', example_2[0][0])
```

    [[0.99089925]]  - Correct:  1
    [[0.006409]]  - Correct:  0

```python
plt.figure(figsize=(15,5))
plt.plot(NN.epoch_list, NN.error_history)
plt.xlabel('Epoch')
plt.ylabel('Loss')
```

```python

```


# Kullback-Leibler divergence

This reading will review the definition of the Kullback-Leibler (or KL) divergence, look at some of its important properties, see how it can be computed in practice with TensorFlow Probability.

```python
import tensorflow as tf
import tensorflow_probability as tfp
tfd = tfp.distributions
print("TF version:", tf.__version__)
print("TFP version:", tfp.__version__)

# Additional packages for the reading
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Ellipse
```

    TF version: 2.7.0
    TFP version: 0.15.0

## Introduction

As you have already seen, the KL divergence is used in variational inference to score the dissimilarity between two distributions. In this reading, we will examine KL divergence more closely. We will see the definition of the KL divergence and some important properties, as well as how it can be computed using `tfd.kl_divergence` and Monte Carlo estimation.

## Definition of the Kullback-Leibler divergence

Given two probability density or mass functions $q(x)$ and $p(x)$, the Kullback-Leibler divergence between them is defined as

\begin{equation}
    D_{KL}\big[q \ || \ p\big] =\begin{cases}
                                        \text{E}_{X\sim q}\big[
                                           \log q(X) - \log p(X)\big] &\text{if } p(x) = 0 \implies q(x) = 0,\\
                                        \infty &\text{otherwise.}
                                       \end{cases}
\end{equation}

The condition $p(x) = 0 \implies q(x) = 0$ - _absolute continuity_ - ensures that the $\log$ in the expectation is well-defined for all $x$ in the support of $q$.

As was mentioned, the KL divergence is a score for the disagreement of two distributions in their placement of probability mass. A smaller score indicates a greater degree of agreement.

## Properties

The Kullback-Leibler divergence is asymmetric. In general,

\begin{equation}
    D_{KL}\big[q \ || \ p\big] \neq D_{KL}\big[p \ || \ q \big]
\end{equation}

In variational inference, $q$ is the approximating distribution, while $p$ is the distribution being approximated. The other KL divergence - $D_{KL}[p \ || \ q  ]$ - is also sometimes used as a loss function, for reasons that will become clear later in this reading.

### Gibbs' inequality

A crucial property of the KL divergence is that for all $q$ and $p$,

\begin{equation}
    D_{KL}\big[q \ || \ p\big] \geq 0,
\end{equation}

with equality if and only if $q(x) = p(x)$ almost everywhere. This property is very useful when we are trying to learn a $q$ that is similar to a $p$: if $D_{KL}[q \ || \ p] = 0$, then we know that $q$ is identical to $p$.

### What causes KL divergence to increase?

As an example, take $q(x)$ and $p(x)$ to be probability mass functions, and let $\mathcal{X}$ be $q$'s support. Provided $q$ is absolutely continuous with respect to $p$, we have

\begin{equation}
    D_{KL}\big[q \ || \ p\big] = \sum_{x \in \mathcal{X}} q(x) \log \frac{q(x)}{p(x)}.
\end{equation}

Values of $x$ that $p$ assigns mass to but $q$ does not do not feature in this sum. Superficially, this may suggest that divergence is not increased if $q$ fails to place mass where $p$ does. However, $q$ is a probability mass function, so will inevitably place more mass than $p$ at some other value(s) of $x$. At those other locations, $\log q(x)/p(x) > 0$, so the divergence is increased.

On the other hand, if $q$ places probability mass where $p$ does not, then $D_{KL}\big[q \ || \ p\big]$ is $+\infty$ - the KL divergence severely penalizes $q$ for locating probability mass where $p$ does not!

From this combination of effects, we can conclude that

\begin{equation}
    \text{support}(q) \subseteq \text{support}(p) \implies D_{KL}\big[ q \ || \ p \big] < \infty,
\end{equation}

while

\begin{equation}
    \text{support}(p) \subset \text{support}(q) \implies D_{KL}\big[ q \ || \ p \big] = \infty
\end{equation}

Consequently, the KL divergence favours distributions $q$ that have a support contained in the target distribution's (i.e. $p$'s).

The diagram below illustrates how the KL divergence is affected by the support of two bivariate density functions $q$ and $p$. The hatched regions indicate the support of either function.

```python
_, axs = plt.subplots(1, 2, sharex=True, sharey=True, figsize=(11, 5))

delta = 45.0  # degrees

q_ell_inf = Ellipse((0, 0), 2, 1.5, 45, ec='blue', fc='none',
                    alpha=0.5, label='q(x)', hatch='/')
q_ell_fin = Ellipse((0, 0), 0.5, 0.75, 45, ec='blue', fc='none',
                    alpha=0.5, label='q(x)', hatch='/')
p_ell_inf = Ellipse((0, 0), 1, 1, 45, ec='red', fc='none',
                    alpha=0.5, label='p(x)', hatch='\\')
p_ell_fin = Ellipse((0, 0), 1, 1, 45, ec='red', fc='none',
                    alpha=0.5, label='p(x)', hatch='\\')

# KL divergence is infinite
for ell in [q_ell_inf, p_ell_inf]:
    axs[0].add_artist(ell)
axs[0].legend([q_ell_inf, p_ell_inf], ['Support of q', 'Support of p'], loc='lower right')
axs[0].get_xaxis().set_ticks([])
axs[0].get_yaxis().set_ticks([])

# KL divergence is finite
for ell in [q_ell_fin, p_ell_fin]:
    axs[1].add_artist(ell)
axs[1].legend([q_ell_fin, p_ell_fin], ['Support of q', 'Support of p'], loc='lower right')
axs[1].get_xaxis().set_ticks([])
axs[1].get_yaxis().set_ticks([])

axs[0].set_title(r'$D_{KL}[q \ || \ p] = +\infty$')
axs[1].set_title(r'$D_{KL}[q \ || \ p]$ is finite but non-zero')
plt.xlim(-1, 1)
plt.ylim(-1, 1);
```

## Computing KL divergence in TensorFlow

For some choices of $q$ and $p$, the KL divergence can be evaluated to a closed-form expression.

`tfd.kl_divergence` computes the KL divergence between two distributions analytically, provided the divergence in question has been implemented in the TensorFlow Probability library.

Below is an example that uses `tfd.kl_divergence` to compute $D_{KL}\big[q \ || \ p \big]$ when $q$ and $p$ are univariate normal distributions.

```python
# Simple example

mu_q = 0.
sigma_q = 1.
mu_p = 0.
sigma_p = 0.5
distribution_q = tfd.Normal(loc=mu_q, scale=sigma_q)
distribution_p = tfd.Normal(loc=mu_p, scale=sigma_p)

tfd.kl_divergence(distribution_q, distribution_p) # D_{KL}[q || p]
```

    <tf.Tensor: shape=(), dtype=float32, numpy=0.8068528>

Let's check this value. The KL divergence between two univariate normal distributions can be derived directly from the definition of the KL divergence as

\begin{equation}
    D_{KL}\big[ q \ || \ p\big] = \frac{1}{2}\bigg(\frac{\sigma_q^2}{\sigma_p^2} + \frac{(\mu_q - \mu_p)^2}{\sigma_p^2}  + 2\log \frac{\sigma_p}{\sigma_q} - 1\bigg)
\end{equation}

The value of this function should be equal to that returned by `kl_divergence(distribution_q, distribution_p)`.

```python
# Analytical expression for KL divergence between two univariate Normals

0.5*( (sigma_q/sigma_p)**2 + ((mu_q - mu_p)/sigma_p)**2 + 2*np.log(sigma_p/sigma_q) - 1)
```

    0.8068528194400546

Sure enough, it is.

If a batch of distributions is passed to `kl_divergence`, then a batch of divergences will be returned. `kl_divergence` also supports broadcasting.

```python
# Batch example with broadcasting

distributions_q = tfd.Normal(loc=[0., 1.], scale=1.)
distribution_p = tfd.Normal(loc=0., scale=0.5)
```

```python
# Notice the batch_shape

distributions_q
```

    <tfp.distributions.Normal 'Normal' batch_shape=[2] event_shape=[] dtype=float32>

```python
# [D_{KL}[q_1 || p], D_{KL}[q_2 || p]

tfd.kl_divergence(distributions_q, distribution_p)
```

    <tf.Tensor: shape=(2,), dtype=float32, numpy=array([0.8068528, 2.8068528], dtype=float32)>

`kl_divergence` provides a convenient way of computing the KL divergence for many TensorFlow distributions. As a rule of thumb, it will evaluate successfully provided you pass in two distributions of the same parametric family.

```python
# An example with another distribution

beta_q = tfd.Beta(concentration1=12, concentration0=3)
beta_p = tfd.Beta(concentration1=9, concentration0=3)

tfd.kl_divergence(beta_q, beta_p)
```

    WARNING:tensorflow:@custom_gradient grad_fn has 'variables' in signature, but no ResourceVariables were used on the forward pass.

    <tf.Tensor: shape=(), dtype=float32, numpy=0.09615421>

```python
# An example with a multivariate distribution

cov_q = np.array([[1., 0.5], [0.5, 1.]])
cov_p = np.array([[1., 0.], [0., 1.]])
mvtnormal_q = tfd.MultivariateNormalTriL(loc=[0., 0.], scale_tril=tf.linalg.cholesky(cov_q))
mvtnormal_p = tfd.MultivariateNormalTriL(loc=[0., 0.], scale_tril=tf.linalg.cholesky(cov_p))

tfd.kl_divergence(mvtnormal_q, mvtnormal_p)
```

    <tf.Tensor: shape=(), dtype=float64, numpy=0.14384103622589053>

To see a complete list of distributions for which a KL method is defined, refer to `help(tfd.kl_divergence)`.

If you pass `kl_divergence` a pair distributions for which a KL divergence method is not implemented, an error will be raised:

```python
# uniform_q and beta_p are both uniform distributions with support [0, 1]

uniform_q = tfd.Uniform(low=0., high=1.)
beta_p = tfd.Beta(concentration1=0., concentration0=0.)
```

```python
# kl_divergence has no method for computing their divergence

try:
    tfd.kl_divergence(uniform_q, beta_p)
except Exception as e:
    print(e)
```

    No KL(distribution_a || distribution_b) registered for distribution_a type Uniform and distribution_b type Beta

### When `kl_divergence` fails

If you do not have a closed-form expression for your KL divergence, and it is not implemented in `tfd.kl_divergence`, then you can make a Monte Carlo estimate of it. Simply sample $n$ values $x_1, \ldots, x_n$ from $q$, then evaluate the estimate

\begin{equation}
    \frac{1}{n}\sum_{i=1}^n \log\big[q(x_i)\big] - \log\big[p(x_i)\big]
\end{equation}

In general, the Monte Carlo estimator is unbiased and its variance is inversely proportional to $n$.

To show how the variance of the Monte Carlo estimator varies with $n$, let's attempt to estimate $D_{KL}\big[q \ | \ p\big]$ when $q$ and $p$ are univariate normal distributions. We'll make many estimates for several values of $n$, then plot their absolute error as a function of $n$.

We'll start by evaluating the exact value $D_{KL}\big[q \ | \ p\big]$ using `kl_divergence`. Bear in mind that the Monte Carlo estimate will only be useful in situations where this not possible!

```python
# Evaluate the exact KL divergence

distribution_q = tfd.Normal(loc=0., scale=1.)
distribution_p = tfd.Normal(loc=0., scale=0.5)

exact_kl_divergence = tfd.kl_divergence(distribution_q, distribution_p).numpy() # D_{KL}[q || p]
exact_kl_divergence
```

    0.8068528

Next, we'll define a function for making a Monte Carlo estimate for a given $q$, $p$, and $n$.

```python
# Function to estimate the KL divergence with Monte Carlo samples

def monte_carlo_estimate_of_kl_divergence(n, q_sampler, q_density, p_density):
    '''
        Computes a Monte Carlo estimate of D_{KL}[q || p] using
        n samples from q_sampler.

        q_sampler is a function that receives a positive integer
        and returns as many samples from q.

        Given samples x_1, ..., x_n from q_sampler, the Monte Carlo
        estimate is

            \frac{1}{n}\sum_{i=1}^n \log(q(x_i)) - \log(p(x_i))

        where q and p are density/mass functions.
    '''
    x = q_sampler(n)
    KL_estimate = np.mean(np.log(q_density(x)) - np.log(p_density(x)))
    return(KL_estimate)
```

The code below shows how this function can be used to make a single estimate.

```python
# Single MC estimate

n = 1000 # number of samples used in MC estimate
q_sampler = distribution_q.sample
q_density = distribution_q.prob
p_density = distribution_p.prob

monte_carlo_estimate_of_kl_divergence(n, q_sampler, q_density, p_density)
```

    0.8158228

To see how the estimator's variance decreases with increasing $n$, let's evaluate a few hundred estimates for each point in a grid of $n$ values.

```python
# Create a grid of 8 points

n_grid = 10**np.arange(1, 8)
samples_per_grid_point = 100  # Number of MC estimates to make for each value of n
```

```python
# Array to store results

kl_estimates = np.zeros(shape=[samples_per_grid_point, len(n_grid), 2])
```

```python
# Make 100 MC estimates for each value of n, store the results in kl_estimates

for sample_num in range(samples_per_grid_point):
    for grid_num, n in enumerate(n_grid):
        kl_estimates[sample_num, grid_num, 0] = n
        kl_estimates[sample_num, grid_num, 1] = monte_carlo_estimate_of_kl_divergence(n,
                                                            q_sampler, q_density, p_density)
```

```python
# Compute RMSE of estimates (this is approximately equal to the standard deviation of the MC estimator)

rmse_of_kl_estimates = np.sqrt(np.mean((kl_estimates[:, :, 1] -
                                            exact_kl_divergence)**2, axis=0))
```

```python
# Compute absolute error of the MC estimates

abs_error_of_kl_estimates = abs(kl_estimates[:, :, 1].flatten() - exact_kl_divergence)
```

```python
# Plot the results

_, ax = plt.subplots(1, 1, figsize=(15, 5))
plt.xlabel(r'Number of samples in Monte Carlo estimate, $n$')
ax.scatter(kl_estimates[:, :, 0],
           abs_error_of_kl_estimates,
           marker='.', color='red',
           alpha=0.1, label='Absolute error of Monte Carlo estimates')
ax.plot(n_grid, rmse_of_kl_estimates, color='k', label='RMSE of Monte Carlo estimates')
ax.set_xscale('log'); ax.set_yscale('log'); ax.set_ylim([1e-6, 10])
ax.legend();
```

You can see that the gradient of the estimates' RMSE, an estimate of the MC estimator's standard devation, with respect to $n$ is $-\frac{1}{2}$. This is unsurprising: the estimator's variance is inversely proportional to $n$, so its log standard deviation is a linear function of $\log n$ with gradient $-\frac{1}{2}$. As $n$ increases, the Monte Carlo estimates approach the exact value of the KL divergence.

### Summary

You should now feel confident about how the Kullback-Leibler divergence is motivated and defined, what its key properties and why it is used in variational inference, and how it can be computed or estimated in TensorFlow.

### Further reading and resources

* TensorFlow documentation on `tfd.kl_divergence`: https://www.tensorflow.org/probability/api_docs/python/tfp/distributions/kl_divergence

## Appendix

#### Information gain, relative entropy, and Bayesian inference

This section provides further context for the Kullback-Leibler divergence. It is not essential, but it will give you a more complete understanding of what the divergence measures.

The Kullback-Leibler divergence has its origins in information theory. The Shannon entropy, defined as

\begin{equation}
    H(P) := E_{X \sim P(x)}[-\log P(X) ]
\end{equation}

is the greatest lower bound on the average number of nats ($\log 2$ nats are equal to $1$ bit) required to losslessly encode an observation sampled from from $P(x)$. This is an informal statement of a result known as the _source coding theorem_. $-\log P(x)$ is the number of bits used to encode $x$ in the lossless encoding scheme.

Say that a lossless compression algorithm instead encodes observations using a scheme that would be optimal for distribution $Q(x)$. Then the average number of of bits required to encode an observation sampled from $P(x)$ would be

\begin{equation}
    H(P, Q) := E_{X \sim P(x)}[-\log Q(X)]
\end{equation}

This is quantity is referred to as the _cross-entropy_ between $P$ and $Q$. Since $H(P)$ is the minimum average information for encoding observations from $P(x)$ by definition, it follows that $H(P, Q) \geq H(P)$.

The Kullback-Leibler divergence is defined as the average additional information required to encode observations from $P(x)$ using an optimal code for $Q(x)$:

\begin{align}
    D_{KL}(P \ || \ Q) &:= E_{X \sim P(x)}[-\log Q(X)] - E_{X \sim P(x)}[-\log P(X)] \\
                       &= H(P, Q) - H(P)
\end{align}

The KL divergence therefore tells us how inefficient the optimal coding scheme for $Q$ is when applied to data source $P$.

That KL divergence is the difference between a cross-entropy and a Shannon entropy sheds light on why the KL divergence has another moniker - _relative entropy_.

Alternatively, we might consider encoding observations in the context of Bayesian inference. Let $P(y)$ be the prior and $P(y|x)$ be the posterior. Then the Kullback-Leibler divergence

\begin{equation}
    D_{KL}(P(y|x) \ || \ P(y)) = E_{Y \sim P(y|x)}[-\log P(Y)] - E_{Y \sim P(y|x)}[-\log P(Y|x)]
\end{equation}

is the average number of bits saved if observations are encoded using an optimal code for the posterior rather than the prior. In this sense, the KL divergence tells us how much information is gained by conditioning on $X$.

```python

```


# Overview of the Devito domain specific language

```python
from sympy import *
from devito import *
```

## From equations to code in a few lines of Python -- the main objective of this notebook is to demonstrate how Devito and its [SymPy](http://www.sympy.org/en/index.html)-powered symbolic API can be used to solve partial differential equations using the finite difference method with highly optimized stencils in a few lines of Python.

## Defining the physical domain

A `Grid` object stores, among other things:

* the physical `extent` (the size) of our domain, and
*
how many points we want to use in each dimension to discretise our data.

```python
grid = Grid(shape=(5, 6), extent=(1., 1.))
grid
```

## Functions, data, and expressions

To express our equation in symbolic form and discretise it using finite differences, Devito provides a set of `Function` types. A `Function` object also carries data.

```python
f = Function(name='f', grid=grid)
f
```

```python
f.data
```

By default, Devito `Function` objects use the spatial dimensions `(x, y)` for 2D grids and `(x, y, z)` for 3D grids. To solve a PDE over several timesteps a time dimension is also required by our symbolic function. For this Devito provides an additional function type, the `TimeFunction`, which incorporates the correct dimension along with some other intricacies needed to create a time stepping scheme.

```python
g = TimeFunction(name='g', grid=grid)
g
```

Since the default time order of a `TimeFunction` is `1`, the shape of `f` is `(2, 5, 6)`, i.e. Devito has allocated two buffers to represent `g(t, x, y)` and `g(t + dt, x, y)`:

```python
g.shape
```

We can also create `Function` objects with custom `Dimension`'s.

```python
x, y = grid.dimensions
d = Dimension(name='d')
```

```python
u1 = Function(name='u', dimensions=(d, x, y), shape=(3,) + grid.shape)
u1
```

```python
u2 = Function(name='u', dimensions=(y, x, d), shape=(6, 5, 3))
u2
```

`Function`'s are used to construct expressions. There is virtually no limit to the complexity an expression can have, but there's a rule -- it must be possible to construct an ordering of `Dimension`'s. In practice, this is never an issue.

```python
cos(g)*f + sin(u1)  # OK, Devito can compile this expression
```

```python
cos(g)*f + sin(u2)  # Not OK, Devito will complain because it sees both `x, y` and `y, x` as Function dimensions
```

## Derivatives of symbolic functions

Devito provides a set of shorthand expressions (implemented as Python properties) that allow us to generate finite differences in symbolic form. For example, the property `f.dx` denotes $\frac{\partial}{\partial x} f(x, y)$ - only that Devito has already discretised it with a finite difference expression.

```python
f.dx
```

We can express derivatives of arbitrary order, but for this we'll need to define a `Function` with a suitable spatial order. For example, the shorthand for the second derivative in `x` is `.dx2`, for the third order derivative `.dx3`, and so on.

```python
h = Function(name='h', grid=grid, space_order=2)
h.dx2
```

We may also want to take a look at the stencil Devito will generate based on the chosen discretisation.

```python
f.dx.evaluate
```

```python
h.dx2.evaluate
```

A similar set of expressions exist for each spatial dimension defined on our grid, for example `f.dy` and `f.dyl` (here the `l` represents the left derivative). Obviously, one can also take derivatives in time of `TimeFunction` objects. For example, to take the first derivative in time of `g` you can simply write:

```python
g.dt
```

There also exist convenient shortcuts to express the forward and backward stencil points, `g(t+dt, x, y)` and `g(t-dt, x, y)`.

```python
g.forward
```

```python
g.backward
```

And of course, there's nothing to stop us taking derivatives on these objects:

```python
g.forward.dt
```

```python
g.forward.dy
```

There also are shortcuts for classic differential operators

```python
h.laplace
```

```python
h.dx2 + h.dy2  # Equivalent to h.laplace
```

## Some advanced features

More generally, we can take **derivatives of arbitrary expressions**

```python
(g.dt + h.laplace + f.dy).dx2
```

Which can, depending on the chosen discretisation, lead to fairly complex stencils:

```python
(g.dt + h.laplace + f.dy).dx2.evaluate
```

The DSL also extends naturally to **tensorial objects**

```python
A = TensorFunction(name='A', grid=grid, space_order=2)
A
```

```python
v = VectorFunction(name='v', grid=grid, space_order=2)
v
```

```python
b = A*v
b
```

```python
div(b)
```

## A linear convection operator

**Note:** The following example is derived from [step 5](http://nbviewer.ipython.org/github/barbagroup/CFDPython/blob/master/lessons/07_Step_5.ipynb) in the excellent tutorial series [CFD Python: 12 steps to Navier-Stokes](http://lorenabarba.com/blog/cfd-python-12-steps-to-navier-stokes/).

In this simple example we will show how to derive a very simple convection operator from a high-level description of the governing equation.

The governing equation we want to implement is the **linear convection equation**. We start off defining some parameters, such as the computational grid. We also initialize our velocity `u` with a smooth field:

```python
from examples.cfd import init_smooth, plot_field

grid = Grid(shape=(81, 81), extent=(2., 2.))
u = TimeFunction(name='u', grid=grid, space_order=8)

# We can now set the initial condition and plot it
init_smooth(field=u.data[0], dx=grid.spacing[0], dy=grid.spacing[1])
init_smooth(field=u.data[1], dx=grid.spacing[0], dy=grid.spacing[1])

plot_field(u.data[0])
```

In particular, the linear convection equation that we want to implement is

$$\frac{\partial u}{\partial t}+c\frac{\partial u}{\partial x} + c\frac{\partial u}{\partial y} = 0.$$

Using the Devito shorthand notation, we can express the governing equations as:

```python
c = 1.  # Value for c

eq = Eq(u.dt + c * u.dxl + c * u.dyl, 0)
```

We now need to rearrange our equation so that the term `u(t+dt, x, y)` is on the left-hand side, since it represents the next point in time for our state variable $u$. Here, we use the Devito built-in `solve` function to create a valid stencil for our update to `u(t+dt, x, y)`:

```python
update = Eq(u.forward, solve(eq, u.forward))
update
```

Once we have created this `update` expression, we can create a Devito `Operator`. This `Operator` will basically behave like a Python function that we can call to apply the created stencil over our associated data.

```python
op = Operator(update)  # Triggers compilation into C !
```

```python
nt = 100  # Number of timesteps
dt = 0.2 * 2. / 80  # Timestep size (sigma=0.2)

op(t=nt+1, dt=dt)
plot_field(u.data[0])
```

Note that the real power of Devito is hidden within `Operator`, it will automatically generate and compile the optimized C code. We can look at this code (noting that this is not a requirement of executing it) via:

```python
print(op.ccode)
```

## What is not covered by this notebook

* Mechanisms to expression injection and interpolation at grid points ("sparse operations")
* Subdomains and Conditionals
* Boundary conditions (w/ and w/o subdomains)
* Custom stencil coefficients
* Staggered grids
* ...

## How do I get parallel code?

```python
op = Operator(update, language='openmp')
print(op)
```

```python
op = Operator(update, language='openacc', platform='nvidiaX')
print(op)
```


### Instructions

When running the notebook the first time, make sure to run all cells before making changes in the notebook. Hit Shift + Enter to run the selected cell or, in the top menu, click on: `Kernel` > `Restart Kernel and Run All Cells...` to rerun the whole notebook. If you make any changes in a cell, rerun that cell.

If you make any changes in a coding cell, rerun the notebook by `Run` > `Run Selected Cell and All Below`

```python
# Importing Python libraries and dependencies for our calculations
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)      # Ignore warnings
warnings.simplefilter(action='ignore', category=UserWarning)        # Ignore warnings
import time                              # Timing the runtime of the notebook
startTime = time.time()
import matplotlib.image as mpimg         # Matplotlib library for displaying images
from matplotlib import pyplot as plt     # Matplotlib library for plotting
import numpy as np                       # Numpy library for basic calculations
import scipy.integrate as si             # For integration
import scipy.optimize as so              # For finding the root of the gamma function
import scipy.special as ss               # For the gamma function
import sys;
sys.path.append('python/');
import bulge_library as b                # For parameters of the 3 galaxies
```

# Bulge Rotation Curve

```python
galaxy = "NGC891"         # Change this value to select a galaxy out of the 3 choices: NGC5533, NGC891, NGC7814
```

After choosing a galaxy, restart the notebook by selecting `Kernel` > `Restart Kernel and Run All Cells` in the top bar.

```python
# Writing the chosen galaxy name in a text file would allow us
# to use the selection in libraries outside of this notebook
f = open("python/galaxy_bulgeactivity.txt", "w")
f.write(galaxy)
f.close()
f = open("python/galaxy_bulgeactivity.txt", "r")
f.close()
```

```python
#NBVAL_IGNORE_OUTPUT
#Because the filepath won't be the same across devices
import importlib
importlib.reload(b)             # Reload library so the changes take effect
```

    <module 'bulge_library' from 'python\\bulge_library.py'>

### Introduction

__What is a bulge?__

A bulge is the bright central part of a spiral galaxy. In shape, bulges are ellipsoidal (spherically squished, to be scientific) and dense with stars, containing most of a spiral galaxy's visible matter.

### Equations

Calculate the rotational velocity due to stars in the bulge using the empirically derived bulge parameters.

>__Gamma function__:

    \begin{equation}
    \gamma(b_n,2n) = \frac{1}{2} \Gamma(2n)
    \end{equation}

    \begin{equation}
    r_e = b_n^n r_0 b_n
    \end{equation}

>__Luminosity in terms of central surface brightness__:

    \begin{equation}
    I_0 = L_b \frac{(b_n)^{2n}}{2\pi n \ r^2_e \ \Gamma(2n)}
    \end{equation}

>where:

        $I_0$ = central surface brightness

        $L_b$ = luminosity of bulge

        $n$ = concentration parameter

        $r_0$ = characteristic radius (related to $r_e$)

        $r_e$ = effective radius

>__Rotational Velocity__:

    \begin{equation}
V_{B}^{2}(r) = C \int_{m=0}^{r} \left[\int_{\kappa=m}^{\infty} \frac {e^{-\left(\frac{\kappa}{r_0}\right)^{\frac{1}{n}}} \left(\frac{\kappa}{r_0}\right)^{\frac{1}{n}-1}} {\sqrt{\kappa^2-m^2}} d\kappa\right] \times \frac{m^{2}}{\sqrt{r^{2}-m^{2}e^{2}}} dm
    \end{equation}

>where:

    \begin{equation}
    C = \frac{4Gq\Upsilon_b I_0}{r_0n} \sqrt{sin^{2}i + \frac{1}{q^{2}} cos^{2}i}
    \end{equation}

    \begin{equation}
    e = \sqrt{1-q^{2}}
    \end{equation}

        $\Upsilon_b$ = mass-to-light ratio

        $e$ = eccentricity of the bulge

        $q$ = intrinsic axis ratio

        $i$ = inclination angle

        $G$ = gravitational constant

### Definitions of necessary bulge parameters

Concentration parameter ($n$) [unitless]: also known as the bulge Sersic parameter _"describes the curvature of the profile in a radius-magnitude plot"_ (Noordermeer 2008).

Intrinsic axis ratio ($q$) [unitless]: _"q [is] the intrinsic axis ratio of the bulge isodensity surfaces"_ (Noordermeer 2008). This parameter describes how flat a galaxy appears. The eccentricity of the galaxy can be calculated using this parameter: $e$ = $\sqrt{1 - q^{2}}$.

Inclination angle ($i$) [degrees]: the angle of the galaxy from the horizontal.

Effective radius ($r_e$) [kpc]: _"the radius which encompasses 50 percent of the light"_ within the bulge (Noordermeer 2008).

Luminosity ($L_b$) [Lsun]: Total galactic luminosity of the bulge of the galaxy, in units of solar luminosity.

Mass-to-light ratio ($\Upsilon_b$) [unitless]: the ratio of how much mass there is compared to how much it is visible.

Distance ($D$) [Mpc]: the distance to the galaxy is useful when converting angular size to actual size.

### Literature search

There are **five parameters** you will need to find to calculate the velocity of the bulge of your chosen galaxy: concentration parameter ($n$), intrinsic axis ratio ($q$), inclination angle ($i$), effective radius ($r_e$), luminosity of the bulge ($L_b$). In addition, you may also need to find the distance to the galaxy ($D$).

Activity 1)

>Do some research to find these parameters for any of the three galaxies (NGC 5533, NGC 891, NGC 7814) of your choice. This activity is intended to give you a glimpse into scholarly literature search. One place to start is by searching for your chosen galaxy at https://ui.adsabs.harvard.edu. You can also choose an entirely different galaxy and calculate the velocity of stars in its bulge. Make sure to have the right units for each parameter.

#### Concentration parameter [unitless]

```python
#n = ?
n = b.n          # Import
```

#### Intrinsic axis ratio [unitless]

```python
#q = ?
q = b.q
```

#### Inclination angle [degrees]

Inclination angle is usually given in degrees. We need to convert this to radians.

```python
#i_degrees = ?
i_degrees = b.i

# Equation to convert degrees to radians
i_rad = i_degrees * (np.pi/180)
```

#### Effective radius [kpc]

```python
#re_kpc = ?
re_kpc = b.re_kpc
```

The effective radius might be given in units of arcseconds. Arcsecond is a unit of an angle of how large something appears in the sky. In this case, you can use trigonometry to calculate the actual size. For this computation, find the distance to your galaxy to be able to convert from arcseconds to kiloparsec.

If the effective radius is already in the right units (in kpc), comment out the next `Code` cell.

Using a right triangle and a small angle approximation, convert arcseconds to kpc in the following way:

>__Getting the right units__:

    \begin{equation}
    1 \rm \ arcsec = \frac{1^\circ}{3600} = \frac{\pi}{180^\circ} \times \frac{1^\circ}{3600} = \frac{\pi}{648000} \ radians
    \end{equation}

    \begin{equation}
    1 \rm \ Mpc = 1000 \rm \ kpc
    \end{equation}

>__Arcsec to kpc__:

    \begin{equation}
    \tan (\theta) \approx \theta \approx \frac{S}{D}
    \end{equation}

    \begin{equation}
    S \rm \ (in \ kpc) \approx \theta \ (in \ rad) \times D \ (in \ kpc)
    \end{equation}

where:

        $\theta$ = angle the object appears in the sky

        $S$ = actual size of the object

        $D$ = distance to the object

```python
### Converting the effective radius in arcsec to kpc (if needed).
### If the conversion is not needed, comment out this section.

try:
    # Parameters found
    #re_arcsec = ?
    #D_Mpc = ?
    re_arcsec = b.re_arcsec
    D_Mpc = b.D_Mpc

    re_rad = re_arcsec * (np.pi / (3600 * 180))      # arcsec to radians
    D_kpc = D_Mpc * 1000                             # Mpc to kpc
    re_kpc = re_rad * D_kpc

    # Print value
except AttributeError:
    re_kpc = b.re_kpc
print('The effective radius of ' + galaxy + ' is '+"{:.2f}".format(re_kpc)+' kpc')
```

    The effective radius of NGC891 is 1.80 kpc

#### Luminosity of the bulge

```python
#L = ?
Lb = b.Lb
```

In some cases, it is easier to find the absolute magnitude of the bulge rather than the luminosity. For example: The (R-band) absolute magnitude of the bulge ($M_b$) for NGC 5533 is listed in Source #2 (Noordermeer and Van Der Hulst, 2007), in Table A4 (Appendix) as -21.66.

Using the zero-point luminosity equation, calculate the luminosity of the bulge (in Watts):

>__Total luminosity of the bulge__:

    \begin{equation}
    L_b = L_0 \times 10^{-0.4 M_b}
    \end{equation}

>where:

        $L_0 = 3.0128 \times 10^{28} W$ = zero-point luminosity

```python
#Mabs = ?
Mabs = b.Mabs
```

```python
### Converting absolute magnitude to luminosity (if needed).
### If the conversion is not needed, comment out this section.

# Zero point luminosity in Watts
L0 = 3.0128e28

# Solar luminosity in Watts
Lsun = 3.838e26

# Equation to find the luminosity in Watts
L_Watts = L0 * 10**(-0.4 * Mabs)

# Convert Watts to Solar luminosity (assuming the solar luminosity is a constant)
# Convert units (Watts to Lsun)
Lb = L_Watts / Lsun

print("The total luminosity of the bulge is {:.3e} Lsun.".format(Lb))
```

    The total luminosity of the bulge is 2.202e+10 Lsun.

#### Mass-to-light ratio

For simplicity, we are assuming a mass-to-light ratio of 1. However, this value can be found by fitting the total curve (that contains the bulge curve) to the measured values. You may also find this value during your literature search.

```python
ML = 1        # Mass-to-light ratio of bulge [unitless]
```

### Calculation

Calculate the velocity of the bulge using the equations discussed above.

```python
def v_bulge(r,L=Lb,re=re_kpc,n=n,q=q,i=i_rad,ML=ML):

    # Constant
    G = 4.300e-6       # Gravitational constant, [kpc*(km/s)**2/Msun]

    # Gamma function
    gammafunction = lambda x: ss.gammainc(2*n,x) * ss.gamma(2*n) - 0.5 * ss.gamma(2*n)
    bn = so.brentq(gammafunction,0,500000,
                 rtol=0.000001,maxiter=100)  # Comes within 1% of exact root within 100 iterations

    # Calculating I0
    I0 = L * bn**(2*n) / (2 * np.pi * n * re**2 * ss.gamma(2*n))    # Central surface brightness

    # Calculating r0
    r0 = re / (bn**n)                                               # Characteristic radius (in kpc)

    # Integrand 1
    integrand1 = lambda x,m: np.exp(-np.power(x/r0,(1/n))) * np.power(x/r0,(1/n)-1) / (np.sqrt(x**2 - m**2))

    # Integrate integrand 1
    f_integrate1 = lambda m: si.quad(integrand1, m, np.inf, args=(m,))[0]
    f_vect1 = np.vectorize(f_integrate1)

    # Define C (constant)
    C = ((4 * G * q * ML * I0) / (r0 * n)) * (np.sqrt((np.sin(i_rad)**2) + (1/(q**2)) * (np.cos(i_rad)**2)))

    # Define eccentricity
    eccentricity = np.sqrt(1 - (q**2))

    # Integrand 2
    f_integrate2 = lambda m,r: C * f_integrate1(m) * (m**2) / (np.sqrt((r**2) - ((m**2)*(eccentricity**2))))

    # Integrate outer function to get velocity squared
    vsq = lambda r: si.quad(f_integrate2, 0, r, args=(r,))[0]
    vsqv = np.vectorize(vsq)

    # Calculate velocity
    velocity = lambda r: np.sqrt(vsqv(r))

    return velocity(r)
```

```python
# Define radius for plot
radius = np.linspace(0.01,20,100)
```

### Plot the velocity curve for the bulge

Note that this cell will probably take a few minutes to run. Please be patient.

```python
plt.figure(figsize=(11.0,8.0))                                      # Size of the plot
plt.plot(radius, v_bulge(radius), linestyle='solid')                # x and y values of the plot
plt.title(str('Bulge Rotation Curve of ' + galaxy), fontsize=18)    # Title of the plot
plt.xlabel('Radius (kpc)', fontsize=14)                             # Labeling the x-axis
plt.ylabel('Velocity (km/s)', fontsize=14)                          # Labeling the y-axis
plt.ylim(0,350)                                                     # Changing the limit of the y-axis
plt.show()
```

Question 1)

>Does the bulge have any effect on the rotation of the stars at 15 kpc?

Question 2)

>What effect does decreasing the concentration parameter, $n$, have on the rotation curve?

Question 3)

>How does the amplitude of the curve changes when increasing the mass-to-light ratio (ML or $\Upsilon_b$) of the bulge?

### References

>1. Noordermeer, Edo (October 22, 2008). **The Rotation Curves of Flattened Sérsic Bulges.** _Monthly Notices of the Royal Astronomical Society_ 385, no. 3: 1359–64. https://doi.org/10.1111/j.1365-2966.2008.12837.x.

>2. Noordermeer, E., and J. M. Van Der Hulst. **The Stellar Mass Distribution in Early-Type Disc Galaxies: Surface Photometry and Bulge–Disc Decompositions.** _Monthly Notices of the Royal Astronomical Society_ 376, no. 4 (April 2007): 1480–1512. https://doi.org/10.1111/j.1365-2966.2007.11532.x.

>3. Fraternali F, Sancisi R, Kamphuis P. **A tale of two galaxies: light and mass in NGC 891 and NGC 7814.** _Astronomy & Astrophysics_. 2011;531:A64. https://doi:10.1051/0004-6361/201116634.

>4. Mamajek, E. E. et al. (October, 2015) **IAU 2015 Resolution B2 on Recommended Zero Points for the Absolute and Apparent Bolometric Magnitude Scales.** eprint arXiv:1510.06262. https://ui.adsabs.harvard.edu/abs/2015arXiv151006262M/abstract

>5. Carroll, B. W.; Ostlie, D. A. (2006). **An Introduction to Modern Astrophysics (2nd ed.).** Pearson. pp. 60–62. ISBN 978-0-321-44284-0.

>6. Graham, A. W. (2001). **An investigation into the prominence of spiral galaxy bulges.** The Astronomical Journal, 121(2), 820–840. https://doi.org/10.1086/318767

```python
#NBVAL_IGNORE_OUTPUT
#Because the timing won't be exactly the same each time.
executionTime = (time.time() - startTime)
print(f'Execution time: {executionTime:.2f} seconds')
```

    Execution time: 407.37 seconds

### Literature search check

Did you research and locate the parameter values for your chosen galaxy? Click the 'Reveal!' button below to check your work! The results appear in the `Log Console` (select `View`--> `Show Log Console`). If you got any values wrong, you could refer to the citation by each value to see exactly which paper and table the value can be found in.

```python
from IPython.display import display
display(b.button)
b.button.on_click(b.on_button_clicked)
```

    Button(description='Reveal!', style=ButtonStyle())


```python
import numpy as np
import pandas as pd
import linearsolve as ls
import matplotlib.pyplot as plt
plt.style.use('classic')
%matplotlib inline
```

# Discussion: Week 9

In this Notebook, we will investigate how the degree to which the central bank responds to changes inflation affects how exogenous shocks to demand or inflation propagate.

## The New-Keynesian Model

The most basic version of the New-Keynesian Model can be expressed as:

\begin{align}
y_t & = E_t y_{t+1}  - \left( r_{t} - \bar{r}\right) + g_t\\
i_{t} & = r_{t} + E_t \pi_{t+1}\\
i_{t} & = \bar{r} + \pi^T + \phi_{\pi}\big(\pi_t - \pi^T\big) + \phi_{y}\big(y_t - \bar{y}\big)\\
\pi_t -\pi^T & = \beta \left( E_t\pi_{t+1} - \pi^T\right)  + \kappa (y_t -\bar{y})+ u_t,
\end{align}

where: $y_t$ is (log) output, $r_t$ is the real interest rate, $i_t$ is the nominal interest rate, $\pi_t$ is the rate of inflation between periods $t-1$ and $t$, $\bar{r}$ is the long-run average real interest rate or the *natural rate of interest*, $\beta$ is the household's subjective discount factor, and $\pi^T$ is the central bank's inflation target. The coefficients $\phi_{\pi}$ and $\phi_{y}$ reflect the degree of intensity to which the central bank *endogenously* adjusts the nominal interest rate in response to movements in inflation and output.

The variables $g_t$ and $u_t$ represent exogenous shocks to aggregate demand and inflation. They follow AR(1) processes:

\begin{align}
g_{t+1} & = \rho_g g_{t} + \epsilon^g_{t+1}\\
u_{t+1} & = \rho_u u_{t} + \epsilon^u_{t+1}
\end{align}

I have removed the monetary policy from the model because today we are going to focus on demand and inflation shocks. We will use the following parameterization:

| $\bar{y}$ | $\beta$ | $\bar{r}$    | $\kappa$ | $\pi^T$ | $\phi_{\pi}$ | $\phi_y$ | $\rho_g$ | $\rho_u$ |
|-----------|---------|--------------|----------|---------|--------------|----------|----------|----------|
| 0         | 0.995   | $-\log\beta$ | 0.1      | 0.02/4  | —            | 0.5/4    | 0.5      | 0.5      |

```python
# Create a variable called 'parameters' that stores the model parameter values in a Pandas Series. CELL PROVIDED
parameters = pd.Series()
parameters['y_bar'] = 0
parameters['beta'] = 0.995
parameters['r_bar'] = -np.log(parameters.beta)
parameters['kappa'] = 0.1
parameters['pi_T'] = 0.02/4
parameters['phi_pi'] = 1.5
parameters['phi_y'] = 0.5/4
parameters['rho_g'] = 0.5
parameters['rho_u'] = 0.5

# Create variable called 'var_names' that stores the variable names in a list with state variables ordered first
var_names = ['g','u','y','pi','i','r']

# Create variable called 'shock_names' that stores an exogenous shock name for each state variable.
shock_names = ['e_g','e_u']

# Define a function that evaluates the equilibrium conditions of the model solved for zero.
def equilibrium_equations(variables_forward,variables_current,parameters):

    # Parameters
    p = parameters

    # Current variables
    cur = variables_current

    # Forward variables
    fwd = variables_forward

    # IS equation
    is_equation = fwd.y - (cur.r -p.r_bar) + cur.g - cur.y

    # Fisher_equation
    fisher_equation = cur.r + fwd.pi - cur.i

    # Monetary policy
    monetary_policy = p.r_bar + p.pi_T + p.phi_pi*(cur.pi - p.pi_T) + p.phi_y*cur.y - cur.i

    # Phillips curve
    phillips_curve = p.beta*(fwd.pi- p.pi_T) + p.kappa*cur.y + cur.u - (cur.pi-p.pi_T)

    # Demand process
    demand_process = p.rho_g*cur.g - fwd.g

    # Inflation process
    inflation_process = p.rho_u*cur.u - fwd.u

    # Stack equilibrium conditions into a numpy array
    return np.array([
            is_equation,
            fisher_equation,
            monetary_policy,
            phillips_curve,
            demand_process,
            inflation_process
        ])

# Initialize the model into a variable named 'nk_model'
nk_model = ls.model(equations = equilibrium_equations,
                 n_states=2,
                 var_names=var_names,
                 shock_names=shock_names,
                 parameters = parameters)

# Compute the steady state numerically using .compute_ss() method of nk_model
guess = [0,0,0,0.01,0.01,0.01]
nk_model.compute_ss(guess)
```

## Exercise: Demand Shocks and $\phi_{\pi}$

For $\phi_{\pi} = 1.5,2,10,100$, compute impulse responses for output and inflation to a 0.01 unit increase in $g_t$. Simulate a total of 21 periods with the arriving in period 5. Construct a figure with two axes side-by-side with output ploted on the left and inflation plotted on the right.

```python
# Create a variable called 'phi_pi_values' that stores the values for phi_pi provided above.

# Create a figure with dimensions 12x4. PROVIDED
fig = plt.figure(figsize=(12,4))

# Create the left axis. PROVIDED
ax1 = fig.add_subplot(1,2,1)
# Create the right axis. PROVIDED
ax2 = fig.add_subplot(1,2,2)

# Set the title of the left axis. PROVIDED
ax1.set_title('Output')
# Set the title of the right axis. PROVIDED
ax2.set_title('Inflation')

# Set the y-axis label of the left axis. PROVIDED
ax1.set_ylabel('Percent')
# Set the y-axis label of the right axis. PROVIDED
ax2.set_ylabel('Percent')

# Construct a for loop that iterates over the values in phi_pi

    # Set the phi_pi value in nk_model.parameters to the current value of phi_pi

    # Approximate and solve the model. Set log_linear argument to False since the model is already linear

    # Compute impulse responses to a one percent shock to demand

    # Plot output

    # Plot inflation impulse response

# Construct legend. PROVIDED
ax2.legend(loc='center left', bbox_to_anchor=(1, 0.5))
```

**Questions**

1. How do the computed impulse responses for output change as $\phi_{\pi}$ increases from 1.5 to 100?
2. How do the computed impulse responses for inflation change as $\phi_{\pi}$ increases from 1.5 to 100?
3. Does the central banker face a trade-off between stabilizing output fluctuations or stabilizing inflation fluctuations in the presence of a demand shock?

**Answers**

1.

1.

3.

## Exercise: Inflation Shocks and $\phi_{\pi}$

For $\phi_{\pi} = 1.5,2,10,100$, compute impulse responses for output and inflation to a 0.01/4 unit increase in $u_t$. Simulate a total of 21 periods with the arriving in period 5. Construct a figure with two axes side-by-side with output ploted on the left and inflation plotted on the right.

```python
# Create a variable called 'phi_pi_values' that stores the values for phi_pi provided above.

# Create a figure with dimensions 12x4. PROVIDED
fig = plt.figure(figsize=(12,4))

# Create the left axis. PROVIDED
ax1 = fig.add_subplot(1,2,1)
# Create the right axis. PROVIDED
ax2 = fig.add_subplot(1,2,2)

# Set the title of the left axis. PROVIDED
ax1.set_title('Output')
# Set the title of the right axis. PROVIDED
ax2.set_title('Inflation')

# Set the y-axis label of the left axis. PROVIDED
ax1.set_ylabel('Percent')
# Set the y-axis label of the right axis. PROVIDED
ax2.set_ylabel('Percent')

# Construct a for loop that iterates over the values in phi_pi

    # Set the phi_pi value in nk_model.parameters to the current value of phi_pi

    # Approximate and solve the model. Set log_linear argument to False since the model is already linear

    # Compute impulse responses to a one percent shock to demand

    # Plot output

    # Plot inflation impulse response

# Construct legend. PROVIDED
ax2.legend(loc='center left', bbox_to_anchor=(1, 0.5))
```

**Questions**

1. How do the computed impulse responses for output change as $\phi_{\pi}$ increases from 1.5 to 100?
2. How do the computed impulse responses for inflation change as $\phi_{\pi}$ increases from 1.5 to 100?
3. Does the central banker face a trade-off between stabilizing output fluctuations or stabilizing inflation fluctuations in the presence of an inflation shock?

**Answers**

1.

1.

3.