# Networks in NeuroDyn

## Overview

This notebook provides a guide to specyfing neural interconnections and designing networks using Hodgkin-Huxley and NeuroDyn models in Python environment. We first start with an overview of two main types of neural interconnections: gap junctions and synapses, and how to implement them in the software environment. The environment allows defining arbitrary conductance-based models of synapses, and we give an example here using an excitatory AMPA synapse. Networks can be constructed using both the standard Hodgkin-Huxley models as well as NeuroDyn neurons, and the notebook shows how we can specify both types.

Finally, we introduce two additional classes `NeuroDynBoard` and `NeuroCube` which specify the NeuroDyn hardware structure:
- `NeuroDynBoard` is a single NeuroDyn board containing 4 neurons and 12 synapses.
- `NeuroCube` is a parallel interconnection of 4 NeuroDyn boards, where the boards are connected through mutural resistive connections.

## Setting up the environment

Let's first import the required Python modules and model definitions:

```python
import matplotlib.pyplot as plt
from cb_models import HHModel, NeuroDynModel, NeuronalNetwork, Synapse, NDSynapse
from cb_models import AMPA
from cb_models import NeuroDynBoard, NeuroCube
from fitting_utilities import FitND

# **Ignore overflow warnings**
import numpy as np
np.seterr(all="ignore")

%matplotlib inline
%load_ext autoreload
%autoreload 2
```

## Overview of neural interconnections

There are two main types of neural interconnections that we can implement: **gap junctions** and **synaptic connections**. In the conductance-based modelling framework, these interconnections lead to additional currents in the membrane equation of neuron $i$ that are dependent on membrane voltages of other neurons $j$ connected to it:

\begin{equation}
C \dot{V}_i = - I_{int,i} - \sum_{j} I_{gap, i, j} - \sum_{j} I_{syn, i, j} + I_{app, i}
\end{equation}

where $I_{int,i}$ is total internal current of the neuron, $I_{gap, i, j}$ is the current due to the gap junction between neurons $j$ and $i$, $I_{syn, i, j}$ is the synaptic current from neuron $j$, and $I_{app, i}$ is the external current applied to neuron $i$.

### Gap junctions

Gap junctions are modeled as simple resistive connections between neurons, so that a resistive connection between neurons $i$ and $j$ leads to a current in neuron $i$:

\begin{equation}
I_{gap, i, j} = g_{gap, i, j} (V_i - V_j)
\end{equation}

Gap junction interconnections are symmetric $g_{gap, i, j} = g_{gap, j, i}$, so that:

\begin{equation}
I_{gap, j, i} = -I_{gap, i, j}
\end{equation}

and neuron $j$ receives the same current of the opposite sign.

We can represent this as a matrix $G_{gap}$ where each element $g_{gap, i, j}$ is the conductance of the connection between neurons $i$ and $j$. This matrix is necessarily **symmetric**, i.e. $G_{gap} = G^T_{gap}$.

### Synaptic connections

Unlike gap junctions, synaptic connections are directed connections and are not symmetric. If there is a synapse from neuron $j$ to neuron $i$, then the activity of the *presynaptic* neuron ($j$) modulates the activity of the *postsynaptic* neuron ($i$). Synaptic currents have a similar form to the internal ionic currents, with the difference that their activation depends on the activity of the presynaptic neuron. This is observed in the dependency of the opening rate $\alpha_r$ on the presynaptic neuron voltage $V_j$:

\begin{equation}
I_{syn, i, j} = \bar{g}_{syn, i, j} r (V_i - E_{syn}) \\
\dot{r} = \alpha_r(V_j) (1 - r) - \beta_r(V_i) r
\end{equation}

The opening of the channels $\alpha_r$ depends on the presynaptic neuron's voltage $V_j$, while the closing depends on the postsynaptic neuron's voltage $V_i$ (sometimes this relationship is modeled as constant, so that $\beta_r = const$).

The reversal potential of the synapse $E_{syn}$ determines if the synapse is *excitatory* or *inhibitory*: excitatory synapses tend to increase the postsynaptic neuron's voltage, while the inhibitory synapses act to decrease the postynaptic voltage. On the other hand, dynamics of activation $r$ determines the timescale of the synaptic current, i.e. how fast it activates in relation to the changes in the presynaptic neuron.

## Defining neural interconnections

Let's now see how we can define these interconnections in the Python environment. We start with a simple example of two Hodgkin-Huxley neurons, which we can define as before:

```python
neuron1 = HHModel()
neuron2 = HHModel()
```

### Gap junctions

Let's start with a simple network consisting of a single gap junction between these two neurons with $g_{gap} = 0.1$. We do this by defining the matrix `G_gap` and passing it as a keyword argument along with a list of the neurons to our `NeuronalNetwork` class:

```python
neurons = [neuron1, neuron2]
g = 0.1
G_gap = [[0, g], [g, 0]] # needs to be symmetric

network = NeuronalNetwork(neurons, gap = G_gap)
```

We simulate the network exactly as before by calling the method `simulate`. This time, the applied current function needs to return an array of external currents corresponding to each neuron in the `neurons` list. Additionally, we need to make sure that the initial condition array `x0` has the right number of states:

```python
# Simulation time
T = 200
trange = (0, T)

# External current
I1 = 15 # current to neuron 1
I2 = 0 # current to neuron 2
Iapp = lambda t: [I1, I2]

# Initial condition
x0 = [0, 0, 0, 0] * 2

# Simulate
sol = network.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]

plt.figure()
plt.xlabel("Time [ms]")
plt.ylabel("Voltage [mV]")
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.legend()
plt.show()
```

You can change the conductance of the gap junction and observe the different behavior. For very small values of the conductance, the neurons are effectively disconnected and act independently, while gradually increasing the conductance will bring the membrane voltages of the two neurons closer together.

Making $g_{gap}$ large effectively acts as the short-circuit between the two neurons (but be careful about making it too large as it may lead to numerical issues).

### Synaptic connections

Let's now see how we can introduce a synapse from neuron 1 to neuron 2. We first need to create an object for the synapse of the `Synapse` class which takes three arguments: `gsyn` - maximal conductance, `Esyn` - reversal potential, `r` - gating variable object.

The `r` gating variable should be derived from the class `HHKinetics` and needs to provide methods `alpha` and `beta` that define the functions $\alpha_r(V)$ and $\beta_r(V)$.

#### AMPA synapse

As an example, let's take a look at implementing an excitatory AMPA synapse. There is an `AMPA` gating kinetics class already provided that takes the values from Ermentrout et al. 2010 that we can use here. Let's define a synapse using the `AMPA` kinetics class, setting the value of the reversal potential to $E_{AMPA} = 65mV$ (the reversal potential value is shifted to accomodate for the shift in the Hodgkin-Huxley model that sets $V_{rest} = 0mV$):

```python
r = AMPA()
gsyn = 0.05
Esyn = 65
synapse = Synapse(gsyn, Esyn, r)
```

Having defined the model of the synapse that we would like to use, we now need to define the connectivity matrix describing all of the synaptic connections in the network. For this, we will construct a matrix `syns` where each element of the matrix $(i, j)$ contains a **list** of all synaptic connections from neuron $j$ to neuron $i$. If there are no synaptic connections from neuron $j$ to neuron $i$, then we set that matrix element to `None`.

Since we would like to define a single excitatory synapse from neuron 1 to neuron 2, we will put a single `Synapse` object into a list at position (1, 0), filling the rest of the matrix with `None`. Let's create a new network with the single excitatory synapse and simulate it:

```python
syns = [[None, None], [[synapse], None]]

network = NeuronalNetwork(neurons, syns = syns)

# Simulation time
T = 200
trange = (0, T)

# External current
I1 = 15 # current to neuron 1
I2 = 0 # current to neuron 2
Iapp = lambda t: [I1, I2]

# Initial condition
x0 = [0, 0, 0, 0] * 2 + [0] # extra state due to the synapse!

# Simulate
sol = network.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]

plt.figure()
plt.xlabel("Time [ms]")
plt.ylabel("Voltage [mV]")
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.legend()
plt.show()
```

We can observe that each time the first neuron spikes it excites the second neuron. If the strength of the synapse is large enough, the second neuron will start spiking in response to the activity of the second neuron. We can play around with the synaptic strength and observe the different behavior:

```python
synapse.gsyn = 0 # change the synapse strength

# Simulate
sol = network.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]

plt.figure()
plt.xlabel("Time [ms]")
plt.ylabel("Voltage [mV]")
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.legend()
plt.show()
```

#### Defining different synapse models

You can use the `Synapse` class to define arbitrary synaptic connections - the main requirement is implementing a gating variable class inherited from the `HHKinetics` class that needs to define implementations for the methods `alpha(V)` and `beta(V)`. You can take a look at how the `AMPA` class is defined in the `cb_models` file to get a better idea.

**Note** that since our original Hodgkin-Huxley model has parameter values shifted so that the resting potential is at $V_{rest} = 0mV$, you might need to shift biophyiscal values accordingly in order to make the synapse compatible with the model.

Another important thing to note is that when we try to fit activation dynamics to the NeuroDyn hardware, the values should be in **SI units**.

Finally, if we would like to stretch the voltage range of the Hodgkin-Huxley model using the `scl_v` parameter, the range of the synapse would need to be stretched accordingly to accommodate for this. It is therefore best to implement an optional keyword `scl_v` to the gating variable class that would multiply the voltage-related parameters (you can check this for the `AMPA` class and the `HHModel` class in the file `cb_models`).

## NeuroDyn network

Let's see now how we can do the same using NeuroDyn models for the individual neurons, as well as the synapses.

### NeuroDyn synapse

A synaptic connection in the NeuroDyn hardware is implemented in very much the same way as ionic conductance dynamics was for a single neuron (see "NeuroDyn equations" section in **NeuroDyn Python model** notebook). Therefore, a NeuroDyn synapse class `NDSynapse` will be defined using the following digital parameters:

- `dg`: digital value for the maximal conductance
- `dE`: digital value for the reversal potential
- `dIb`: digital values for the alpha and beta sigmoid weights
    - `dIb` is a list containing `[dIalpha, dIbeta]`
    - each `dIalpha` and `dIbeta` is an array of 7 digital values for the sigmoid weights
- `ND`: optional keyword argument containing a `NeuroDynModel` object
    - the synapse will take all physical parameters such as `I_master` and `I_voltage` from this object, so it should be the 'parent' NeuroDyn neuron for the synapse
    - if no `ND` object is passed, `NDSynapse` will be initialized with a default `NeuroDynModel` object

In order to find these digital values we can again try fitting the parameters so that they replicate a particular biophysical synapse. As an example, let's try fitting the parameters to the AMPA synapse of the previous section.

### Fitting a synapse

In order to fit a synapse model, we can again use the class `FitND` that we have previously used to fit a Hodgkin-Huxley model to a NeuroDyn neuron. We first fit an appropriate Hodgkin-Huxley model as before, after which we can pass any other gating variables that we would like to fit to. In this case, we want to fit the AMPA gating variable.

First, let's repeat the procedure of fitting a NeuroDyn neuron as before:

```python
# Fit Hodgkin-Huxley model

scl_v = 3 # voltage scaling

# Hodgkin-Huxley model we want to fit to
hh_neuron = HHModel(scl_v = scl_v, SI_units = True)

# Create a fitting object and obtain the initial fit
fit_obj = FitND(hh_neuron)

# Get the sigmoid weights
weights = fit_obj.fitHH()

g0 = [120e-3,36e-3,0.3e-3] # maximal conductances
E0 = [120e-3,-12e-3,10.6e-3] # reversal potentials

# Get the digital parameters for NeuroDyn
dIb = fit_obj.get_digital_Ib(weights)
dg = fit_obj.get_digital_g(g0)
dE = fit_obj.get_digital_E(E0)
dIb[2][1] = dIb[2][1]*15

I_master = fit_obj.I_master
I_voltage = fit_obj.I_voltage

V_ref = 0.9

# Create a NeuroDyn object
ND1 = NeuroDynModel(dg, dE, dIb, V_ref, I_voltage, I_master)
```

Now, let's follow the same procedure in order to get the parameters for an AMPA synapse. We first make an AMPA gating variable (remember that it needs to be scaled appropriately, which we do by passing the `scl_v` keyword).

We can then pass the `AMPA` gating variable, along with any other gating variables we would like to fit to, to the `fit` method. Importantly, note that a **list** of gating variables should be passed as an argument, along with an optional list `labels` for the figure labels.

Another important thing to note is that each time we fit new gating variables using an existing `FitND` object, the `scl_t` variable determining the time scaling will adjust so that the maximal coefficient encountered so far fits within the NeuroDyn constraints. If we would like to fit gating variables without readjusting the scale, we can pass an optional keyword argument `update_scale = False`. This means that our fitting process might return parameters that are outside the digital range. Here we will keep the default `update_scale` that is set to `True`.

```python
# Fit AMPA gating variable
r = AMPA(scl_v = scl_v, SI_units = True)

# Fit weights to AMPA
weights_syn = fit_obj.fit([r], labels = ['r'], plot_alpha_beta = True) # note that gating variables are passed in a list

weights_syn = weights_syn[0] # fitting method returns a list of gating variables, so get the only element
```

Having obtained the weights for the gating variable kinetics, we can then get the digital parameters for the maximal conductance and the reversal potential of the synapse, and use that to create an `NDSynapse` object. Apart from the maximal conductance, reversal potential, and the gating variable, the `NDSynapse` object also accepts a reference to a NeuroDyn object from which it takes physical constants and currents $I_{voltage}$ and $I_{master}$.

```python
gsyn = 0.3e-3 # max coductance of the synapse
Esyn = 65e-3 # reversal potential of the synapse

dIb_syn = fit_obj.get_digital_Ib(weights_syn)
dgsyn = fit_obj.get_digital_g(gsyn)
dEsyn = fit_obj.get_digital_E(Esyn)

nd_syn = NDSynapse(dgsyn, dEsyn, dIb_syn, ND1)

print("Alpha and beta weights:\n", dIb_syn)
print("Digital value for maximal conductance: ", dgsyn)
print("Digital value for the reversal potential: ", dEsyn)
```

### Creating a network

Having created an object for our synapse, we can now define a network in exactly the same way as before! Let's define a second neuron `ND2` with the same parameters, but let's decrease the applied current to this neuron in order to make it silent. For convenience, we can `deepcopy` our first neuron, so that the second neuron has the same parameters but does not share any variables with the first neuron. Let's set the appropriate current $I_{app}$ so that the neuron is silent:

```python
from copy import deepcopy

ND2 = deepcopy(ND1) # makes an independent copy of the ND1 object

# Set the applied current for ND2 to be silent
I2 = -1e-6
Iapp = lambda t : fit_obj.convert_I(I2)

V_ref = 0.9

T = 0.02
trange = (0, T)

sol = ND2.simulate(trange,[0.7,0,0,0],Iapp)

plt.figure()
plt.xlabel('t [s]')
plt.ylabel('V [V]')
plt.title('ND 2')
plt.plot(sol.t, sol.y[0])
plt.show()
```

Now that we have our two neurons `ND1` and `ND2`, let's set up the excitatory synapse we have created from `ND1` to `ND2` so that the first neuron spiking excites the resting second neuron.

```python
nd_neurons = [ND1, ND2]

nd_syns = [[None, None], [[nd_syn], None]]

nd_network = NeuronalNetwork(nd_neurons, syns = nd_syns)

# Simulation time
T = 0.04
trange = (0, T)

# External currents
I1 = fit_obj.convert_I(0e-6) # current to neuron 1
I2 = fit_obj.convert_I(-1e-6) # current to neuron 2
Iapp = lambda t: [I1, I2]

# Initial condition
x0 = [0.7, 0, 0, 0] * 2 + [0] # extra state due to the synapse

# Simulate
sol = nd_network.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]

plt.figure()
plt.xlabel('t [s]')
plt.ylabel('V [V]')
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.legend()
plt.show()
```

You may notice that after the initial transient has died out, individual spikes in the first neuron induce excitations in the voltage of the second neuron. We can try changing the digital value of the maximal conductance of the synapse to observe its effect on the activity of the second neuron. You should observe that by increasing the synaptic strength the second neuron eventually starts spiking, and its spikes appear more closely to the spikes of the first neuron as it is further increased.

```python
# Change synapse strength
nd_syn.update_dg(0)

# Simulate
sol = nd_network.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]

plt.figure()
plt.xlabel('t [s]')
plt.ylabel('V [V]')
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.legend()
plt.show()
```

## NeuroDyn board classes

So far, we have seen how to define a network of Hodgkin-Huxley or NeuroDyn neurons. The arguments of the network are a full list of the individual neural objects, a conductance matrix defining the gap junction connections, and a synapse matrix defining the synapse connections of the circuit. Using the `NeuronalNetwork` class we can define arbitrary neural networks given by the structure of the gap junction and synapse matrices.

The NeuroDyn hardware however has a fixed structure: a single **NeuroDyn board** consists of **4 neurons** with **12 synapses** defining a network of four fully-connected neurons. The class `NeuroDynBoard` defines a network with this particular architecture, allowing in addition to optionally connect neurons with resistive connections.

By connecting four NeuroDyn boards in parallel through resistive connections, we get a **NeuroCube**. The NeuroCube consists of four NeuroDyn boards stacked on top of each other, so that Neuron 1 of each board can be connected to corresponding Neuron 1 of every other board, Neuron 2 of every board can be connected to every correspodning Neuron 2, and so on, giving in total 24 additional resistive connections that can be controlled. This network is represented by the `NeuroCube` class.

Let's see how we can use these classes.

### NeuroDynBoard

We can create a single NeuroDyn board object by calling the class `NeuroDynBoard`, which will initialize all neurons and synapses with default parameters, as well as set conductance of all resistive connections to $0$.

```python
nd_board = NeuroDynBoard()
```

Let's try simulating the board - since all synapses and gap junctions are initialized with $0$ conductance, we should observe four neurons operating individually. Note that we can reuse are previously defined fitting object in order to convert currents to the right scale.

```python
# Simulation time
T = 0.02
trange = (0, T)

# External currents
I1 = fit_obj.convert_I(-2e-6) # silent
I2 = fit_obj.convert_I(-1e-6) # silent
I3 = fit_obj.convert_I(0e-6) # spiking
I4 = fit_obj.convert_I(2e-6) # spiking
Iapp = lambda t: [I1, I2, I3, I4]

# Initial conditions
x0 = [0.7, 0, 0, 0] * 4 + [0]*12 # 4 neurons, 12 synapses

# Simulate
sol = nd_board.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]
V3 = sol.y[8]
V4 = sol.y[12]

plt.figure()
plt.title("NeuroDyn Board")
plt.xlabel('t [s]')
plt.ylabel('V [V]')
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.plot(sol.t, V3, label = "Neuron 3")
plt.plot(sol.t, V4, label = "Neuron 4")
plt.legend()
plt.show()
```

We can change the parameters of the circuit by accessing individual neurons and synapses. We can do this by using the following methods:

- `get_neuron(i)`: retrive neuron $i$ ($i$ goes from $0$ to $3$)
- `get_syn(i, j)`: retrieve synapse from neuron $j$ to neuron $i$

We can then use the neural and synaptic class methods to update the parameters. In addition we can set the gap junction conductances by calling:

- `set_gap(g, i, j)`: set the maximal conductance of the $(i, j)$ gap junction to $g$

As a test, let's see how we can create a network where neurons excite each other sequentially 1 -> 2 -> 3 -> 4. The default parameters are set to an excitatory synapse, so we only need to increase the maximal conductances of the appropriate connections.

```python
# Get synapses
syn1 = nd_board.get_syn(1, 0) # synapse 1 -> 2
syn2 = nd_board.get_syn(2, 1) # synapse 2 -> 3
syn3 = nd_board.get_syn(3, 2) # synapse 3 -> 4

# Update synapses
syn1.update_dg(5)
syn2.update_dg(5)
syn3.update_dg(2)

# External currents (set neurons 2, 3, 4 to silent)
I1 = -0e-6
I2 = -1e-6
I3 = -1e-6
I4 = -1e-6

Iapp = lambda t: fit_obj.convert_I([I1, I2, I3, I4])

# Initial conditions
x0 = [0.7, 0, 0, 0] * 4 + [0]*12 # 4 neurons, 12 synapses

# Simulate
sol = nd_board.simulate(trange, x0, Iapp)
V1 = sol.y[0]
V2 = sol.y[4]
V3 = sol.y[8]
V4 = sol.y[12]

plt.figure()
plt.title("NeuroDyn Board with excitatory synapses")
plt.xlabel('t [s]')
plt.ylabel('V [V]')
plt.plot(sol.t, V1, label = "Neuron 1")
plt.plot(sol.t, V2, label = "Neuron 2")
plt.plot(sol.t, V3, label = "Neuron 3")
plt.plot(sol.t, V4, label = "Neuron 4")
plt.legend()
plt.show()
```

You can change the maximal conductance parameters of the synapses to make sure the network behaves as expected (for example turning the synapses on or off).

### NeuroCube

In exactly the same manner, we can define an object of the `NeuroCube` class which will initialize 4 disconnected NeuroDyn boards with the default parameters. Let's first set up a default neurocube object and try simulating it. We can for example set the applied current to silence all neurons apart from the neurons on the third board:

```python
neurocube  = NeuroCube()

I_silent = [-1e-6, -1e-6, -1e-6, -1e-6] # all neurons silent
I3 = [0e-6, 1e-6, 2e-6, 3e-6] # all neurons spiking
Itotal = I_silent * 2 + I3 + I_silent # set the neurons of board 3 to spike

Iapp = lambda t: fit_obj.convert_I(Itotal)

x0 = [0.7, 0, 0, 0] * 16 + [0]*48 # 16 neurons, 48 synapses

sol = neurocube.simulate(trange, x0, Iapp)

for i in range(4):
    V1 = sol.y[16 * i + 0]
    V2 = sol.y[16 * i + 4]
    V3 = sol.y[16 * i + 8]
    V4 = sol.y[16 * i + 12]

    plt.figure()
    plt.title(f"NeuroDyn Board {i + 1}")
    plt.xlabel('t [s]')
    plt.ylabel('V [V]')
    plt.plot(sol.t, V1, label = "Neuron 1")
    plt.plot(sol.t, V2, label = "Neuron 2")
    plt.plot(sol.t, V3, label = "Neuron 3")
    plt.plot(sol.t, V4, label = "Neuron 4")
    plt.legend()

plt.show()
```

If we would like to change the parameters of an individual board we can use the method `get_board(i)` which returns the board with the index $i$, after which we can use the methods described in **NeuroDynBoard** section. For example:

`board1 = neurocube.get_board(0)`

An additional method that the `NeuroCube` class provides is `connect_boards(board_i, board_j, neuron_no, g)` which sets the resistive connection between neuron numbered `neuron_no` on board `board_i` and neuron numbered `neuron_no` on board `board_j` with conductance `g`.

Let's for example connect boards 3 and 1 through neuron 1, and boards 3 and 4 through neuron 2.

```python
g = 1e-8
neurocube.connect_boards(2, 0, 0, g) # boards 3 and 1 connected through neuron 1
neurocube.connect_boards(2, 3, 1, g) # boards 3 and 4 connected through neuron 2

sol = neurocube.simulate(trange, x0, Iapp)

for i in range(4):
    V1 = sol.y[16 * i + 0]
    V2 = sol.y[16 * i + 4]
    V3 = sol.y[16 * i + 8]
    V4 = sol.y[16 * i + 12]

    plt.figure()
    plt.title(f"NeuroDyn board {i + 1}")
    plt.xlabel('t [s]')
    plt.ylabel('V [V]')
    plt.plot(sol.t, V1, label = "Neuron 1")
    plt.plot(sol.t, V2, label = "Neuron 2")
    plt.plot(sol.t, V3, label = "Neuron 3")
    plt.plot(sol.t, V4, label = "Neuron 4")
    plt.legend()

plt.show()
```

```python

```


# The BSSN Formulation of General Relativity in Generic Curvilinear Coordinates: An Overview

## Author: Zach Etienne

## This tutorial notebook demonstrates how Einstein's equations of general relativity in this formulation are constructed and output within NRPy+.

### As Einstein's equations in this formalism take the form of highly nonlinear, coupled *wave equations*, the [tutorial notebook on the scalar wave equation in curvilinear coordinates](Tutorial-ScalarWaveCurvilinear.ipynb) is *required* reading before beginning this module. That module, as well as its own prerequisite [module on reference metrics within NRPy+](Tutorial-Reference_Metric.ipynb) provides the needed overview of how NRPy+ handles reference metrics.

## Introduction:
NRPy+'s original purpose was to be an easy-to-use code capable of generating Einstein's equations in a broad class of [singular](https://en.wikipedia.org/wiki/Coordinate_singularity), curvilinear coordinate systems, where the user need only input the scale factors of the underlying reference metric. Upon generating these equations, NRPy+ would then leverage SymPy's [common-expression-elimination (CSE)](https://en.wikipedia.org/wiki/Common_subexpression_elimination) and C code generation routines, coupled to its own [single-instruction, multiple-data (SIMD)](https://en.wikipedia.org/wiki/SIMD) functions, to generate highly-optimized C code.

### Background Reading/Lectures:

* Mathematical foundations of BSSN and 3+1 initial value problem decompositions of Einstein's equations:
    * [Thomas Baumgarte's lectures on mathematical formulation of numerical relativity](https://www.youtube.com/watch?v=t3uo2R-yu4o&list=PLRVOWML3TL_djTd_nsTlq5aJjJET42Qke)
    * [Yuichiro Sekiguchi's introduction to BSSN](http://www2.yukawa.kyoto-u.ac.jp/~yuichiro.sekiguchi/3+1.pdf)
* Extensions to the standard BSSN approach used in NRPy+
    * [Brown's covariant "Lagrangian" formalism of BSSN](https://arxiv.org/abs/0902.3652)
    * [BSSN in spherical coordinates, using the reference-metric approach of Baumgarte, Montero, Cordero-Carrión, and Müller (2012)](https://arxiv.org/abs/1211.6632)
    * [BSSN in generic curvilinear coordinates, using the extended reference-metric approach of Ruchlin, Etienne, and Baumgarte (2018)](https://arxiv.org/abs/1712.07658)

### A Note on Notation:

As is standard in NRPy+,

* Greek indices refer to four-dimensional quantities where the zeroth component indicates temporal (time) component.
* Latin indices refer to three-dimensional quantities. This is somewhat counterintuitive since Python always indexes its lists starting from 0. As a result, the zeroth component of three-dimensional quantities will necessarily indicate the first *spatial* direction.

As a corollary, any expressions involving mixed Greek and Latin indices will need to offset one set of indices by one: A Latin index in a four-vector will be incremented and a Greek index in a three-vector will be decremented (however, the latter case does not occur in this tutorial notebook).

# Table of Contents
$$\label{toc}$$

This module lays out the mathematical foundation for the BSSN formulation of Einstein's equations, as detailed in the references in the above Background Reading/Lectures section. It is meant to provide an overview of the basic equations and point of reference for **full tutorial notebooks** linked below:

1. [Step 1](#brownslagrangebssn): [Brown](https://arxiv.org/abs/0902.3652)'s covariant formulation of the BSSN time-evolution equations (see next section for gauge conditions)
    1. [Step 1.a](#fullequations): Numerical implementation of BSSN time-evolution equations
        1. [Step 1.a.i](#liederivs) ([**BSSN quantities module [start here]**](Tutorial-BSSN_quantities.ipynb); [**BSSN time-evolution module**](Tutorial-BSSN_time_evolution-BSSN_RHSs.ipynb)): Expanding the Lie derivatives; the BSSN time-evolution equations in their final form
1. [Step 2](#gaugeconditions) ([**full tutorial notebook**](Tutorial-BSSN_time_evolution-BSSN_gauge_RHSs.ipynb)): Time-evolution equations for the BSSN |gauge quantities $\alpha$ and $\beta^i$
1. [Step 3](#constraintequations) ([**full tutorial notebook**](Tutorial-BSSN_constraints.ipynb)): The BSSN constraint equations
    1. [Step 3.a](#hamiltonianconstraint): The Hamiltonian constraint
    1. [Step 3.b](#momentumconstraint): The momentum constraint
1. [Step 4](#gammaconstraint) ([**full tutorial notebook**](Tutorial-BSSN-Enforcing_Determinant_gammabar_equals_gammahat_Constraint.ipynb)): The BSSN algebraic constraint $\hat{\gamma}=\bar{\gamma}$
1. [Step 5](#latex_pdf_output) Output this notebook to $\LaTeX$-formatted PDF file

# Step 1: [Brown](https://arxiv.org/abs/0902.3652)'s covariant formulation of BSSN \[Back to [top](#toc)\]
$$\label{brownslagrangebssn}$$

The covariant "Lagrangian" BSSN formulation of [Brown (2009)](https://arxiv.org/abs/0902.3652), which requires

$$
\partial_t \bar{\gamma} = 0,
$$

results in the BSSN equations taking the following form (Eqs. 11 and 12 in [Ruchlin, Etienne, and Baumgarte (2018)](https://arxiv.org/abs/1712.07658)):

\begin{align}
  \partial_{\perp} \bar{\gamma}_{i j} {} = {} & \frac{2}{3} \bar{\gamma}_{i j} \left (\alpha \bar{A}_{k}^{k} - \bar{D}_{k} \beta^{k}\right ) - 2 \alpha \bar{A}_{i j} \; , \\
  \partial_{\perp} \bar{A}_{i j} {} = {} & -\frac{2}{3} \bar{A}_{i j} \bar{D}_{k} \beta^{k} - 2 \alpha \bar{A}_{i k} {\bar{A}^{k}}_{j} + \alpha \bar{A}_{i j} K \nonumber \\
  & + e^{-4 \phi} \left \{-2 \alpha \bar{D}_{i} \bar{D}_{j} \phi + 4 \alpha \bar{D}_{i} \phi \bar{D}_{j} \phi \right . \nonumber \\
    & \left . + 4 \bar{D}_{(i} \alpha \bar{D}_{j)} \phi - \bar{D}_{i} \bar{D}_{j} \alpha + \alpha \bar{R}_{i j} \right \}^{\text{TF}} \; , \\
  \partial_{\perp} \phi {} = {} & \frac{1}{6} \left (\bar{D}_{k} \beta^{k} - \alpha K \right ) \; , \\
  \partial_{\perp} K {} = {} & \frac{1}{3} \alpha K^{2} + \alpha \bar{A}_{i j} \bar{A}^{i j} \nonumber \\
  & - e^{-4 \phi} \left (\bar{D}_{i} \bar{D}^{i} \alpha + 2 \bar{D}^{i} \alpha \bar{D}_{i} \phi \right ) \; , \\
  \partial_{\perp} \bar{\Lambda}^{i} {} = {} & \bar{\gamma}^{j k} \hat{D}_{j} \hat{D}_{k} \beta^{i} + \frac{2}{3} \Delta^{i} \bar{D}_{j} \beta^{j} + \frac{1}{3} \bar{D}^{i} \bar{D}_{j} \beta^{j} \nonumber \\
  & - 2 \bar{A}^{i j} \left (\partial_{j} \alpha - 6 \partial_{j} \phi \right ) + 2 \bar{A}^{j k} \Delta_{j k}^{i} \nonumber \\
  & -\frac{4}{3} \alpha \bar{\gamma}^{i j} \partial_{j} K \\
\end{align}
where
* the $\text{TF}$ superscript denotes the trace-free part.
* $\bar{\gamma}_{ij} = \varepsilon_{i j} + \hat{\gamma}_{ij}$, where $\bar{\gamma}_{ij} = e^{-4\phi} \gamma_{ij}$ is the conformal metric, $\gamma_{ij}$ is the physical metric (see below), and $\varepsilon_{i j}$ encodes information about the non-hatted metric.
* $\gamma_{ij}$, $\beta^i$, and $\alpha$ are the physical (as opposed to conformal) spatial 3-metric, shift vector, and lapse, respectively, which may be defined via the 3+1 decomposition line element (in [$G=c=1$ units](https://en.wikipedia.org/wiki/Planck_units)):
$$ds^2 = -\alpha^2 dt^2 + \gamma_{ij}\left(dx^i + \beta^i dt\right)\left(dx^j + \beta^j dt\right).$$
* $\bar{R}_{ij}$ is the conformal Ricci tensor, computed via
\begin{align}
  \bar{R}_{i j} {} = {} & - \frac{1}{2} \bar{\gamma}^{k l} \hat{D}_{k} \hat{D}_{l} \bar{\gamma}_{i j} + \bar{\gamma}_{k(i} \hat{D}_{j)} \bar{\Lambda}^{k} + \Delta^{k} \Delta_{(i j) k} \nonumber \\
  & + \bar{\gamma}^{k l} \left (2 \Delta_{k(i}^{m} \Delta_{j) m l} + \Delta_{i k}^{m} \Delta_{m j l} \right ) \; .
\end{align}
* $\partial_{\perp} = \partial_t - \mathcal{L}_\beta$; $\mathcal{L}_\beta$ is the [Lie derivative](https://en.wikipedia.org/wiki/Lie_derivative) along the shift vector $\beta^i$.
* $\partial_0 = \partial_t - \beta^i \partial_i$ is an advective time derivative.
* $\hat{D}_j$ is the [covariant derivative](https://en.wikipedia.org/wiki/Covariant_derivative) with respect to the reference metric $\hat{\gamma}_{ij}$.
* $\bar{D}_j$ is the [covariant derivative](https://en.wikipedia.org/wiki/Covariant_derivative) with respect to the barred spatial 3-metric $\bar{\gamma}_{ij}$
* $\Delta^i_{jk}$ is the tensor constructed from the difference of barred and hatted Christoffel symbols:
$$\Delta^i_{jk} = \bar{\Gamma}^i_{jk} - \hat{\Gamma}^i_{jk}$$
    * The related quantity $\Delta^i$ is defined $\Delta^i \equiv \bar{\gamma}^{jk} \Delta^i_{jk}$.
* $\bar{A}_{ij}$ is the conformal, trace-free extrinsic curvature:
$$\bar{A}_{ij} = e^{-4\phi} \left(K_{ij} - \frac{1}{3}\gamma_{ij} K\right),$$
where $K$ is the trace of the extrinsic curvature $K_{ij}$.

## Step 1.a: Numerical implementation of BSSN time-evolution equations \[Back to [top](#toc)\]
$$\label{fullequations}$$

Regarding the numerical implementation of the above equations, first notice the left-hand sides of the equations include the time derivatives. Numerically, these equations are solved using as an [initial value problem](https://en.wikipedia.org/wiki/Initial_value_problem), where data are specified at a given time $t$, so that the solution at any later time can be obtained using the [Method of Lines (MoL)](https://en.wikipedia.org/wiki/Method_of_lines). MoL requires that the equations be written in the form:

$$\partial_t \vec{U} = \vec{f}\left(\vec{U},\partial_i \vec{U}, \partial_i \partial_j \vec{U},...\right),$$

for the vector of "evolved quantities" $\vec{U}$, where the right-hand side vector $\vec{f}$ *does not* contain *explicit* time derivatives of $\vec{U}$.

Thus we must first rewrite the above equations so that *only* partial derivatives of time appear on the left-hand sides of the equations, meaning that the Lie derivative terms must be moved to the right-hand sides of the equations.

### Step 1.a.i: Expanding the Lie derivatives; BSSN equations in their final form \[Back to [top](#toc)\]
$$\label{liederivs}$$

In this Step, we provide explicit expressions for the [Lie derivatives](https://en.wikipedia.org/wiki/Lie_derivative) $\mathcal{L}_\beta$ appearing inside the $\partial_\perp = \partial_t - \mathcal{L}_\beta$ operators for $\left\{\bar{\gamma}_{i j},\bar{A}_{i j},W, K, \bar{\Lambda}^{i}\right\}$.

In short, the Lie derivative of tensor weight $w$ is given by (from [the wikipedia article on Lie derivatives](https://en.wikipedia.org/wiki/Lie_derivative))
\begin{align}
(\mathcal {L}_X T) ^{a_1 \ldots a_r}{}_{b_1 \ldots b_s} &= X^c(\partial_c T^{a_1 \ldots a_r}{}_{b_1 \ldots b_s}) \\
&\quad - (\partial_c X ^{a_1}) T ^{c a_2 \ldots a_r}{}_{b_1 \ldots b_s} - \ldots - (\partial_c X^{a_r}) T ^{a_1 \ldots a_{r-1}c}{}_{b_1 \ldots b_s} \\
 &\quad  +  (\partial_{b_1} X^c) T ^{a_1 \ldots a_r}{}_{c b_2 \ldots b_s} + \ldots + (\partial_{b_s} X^c) T ^{a_1 \ldots a_r}{}_{b_1 \ldots b_{s-1} c} + w (\partial_{c} X^c) T ^{a_1 \ldots a_r}{}_{b_1 \ldots b_{s}}
\end{align}

Thus to evaluate the Lie derivative, one must first know the tensor density weight $w$ for each tensor. In this formulation of Einstein's equations, **all evolved quantities have density weight $w=0$**, so according to the definition of Lie derivative above,
\begin{align}
\mathcal{L}_\beta \bar{\gamma}_{ij} &= \beta^k \partial_k \bar{\gamma}_{ij} + \partial_i \beta^k \bar{\gamma}_{kj} + \partial_j \beta^k \bar{\gamma}_{ik}, \\
\mathcal{L}_\beta \bar{A}_{ij} &= \beta^k \partial_k \bar{A}_{ij} + \partial_i \beta^k \bar{A}_{kj} + \partial_j \beta^k \bar{A}_{ik}, \\
\mathcal{L}_\beta \phi &= \beta^k \partial_k \phi, \\
\mathcal{L}_\beta K &= \beta^k \partial_k K, \\
\mathcal{L}_\beta \bar{\Lambda}^i &= \beta^k \partial_k \bar{\Lambda}^i - \partial_k \beta^i \bar{\Lambda}^k
\end{align}

With these definitions, the BSSN equations for the un-rescaled evolved variables in the form $\partial_t \vec{U} = f\left(\vec{U},\partial_i \vec{U}, \partial_i \partial_j \vec{U},...\right)$ become

\begin{align}
  \partial_t \bar{\gamma}_{i j} {} = {} & \left[\beta^k \partial_k \bar{\gamma}_{ij} + \partial_i \beta^k \bar{\gamma}_{kj} + \partial_j \beta^k \bar{\gamma}_{ik} \right] + \frac{2}{3} \bar{\gamma}_{i j} \left (\alpha \bar{A}_{k}^{k} - \bar{D}_{k} \beta^{k}\right ) - 2 \alpha \bar{A}_{i j} \; , \\
  \partial_t \bar{A}_{i j} {} = {} & \left[\beta^k \partial_k \bar{A}_{ij} + \partial_i \beta^k \bar{A}_{kj} + \partial_j \beta^k \bar{A}_{ik} \right] - \frac{2}{3} \bar{A}_{i j} \bar{D}_{k} \beta^{k} - 2 \alpha \bar{A}_{i k} {\bar{A}^{k}}_{j} + \alpha \bar{A}_{i j} K \nonumber \\
  & + e^{-4 \phi} \left \{-2 \alpha \bar{D}_{i} \bar{D}_{j} \phi + 4 \alpha \bar{D}_{i} \phi \bar{D}_{j} \phi  + 4 \bar{D}_{(i} \alpha \bar{D}_{j)} \phi - \bar{D}_{i} \bar{D}_{j} \alpha + \alpha \bar{R}_{i j} \right \}^{\text{TF}} \; , \\
  \partial_t \phi {} = {} & \left[\beta^k \partial_k \phi \right] + \frac{1}{6} \left (\bar{D}_{k} \beta^{k} - \alpha K \right ) \; , \\
  \partial_{t} K {} = {} & \left[\beta^k \partial_k K \right] + \frac{1}{3} \alpha K^{2} + \alpha \bar{A}_{i j} \bar{A}^{i j} - e^{-4 \phi} \left (\bar{D}_{i} \bar{D}^{i} \alpha + 2 \bar{D}^{i} \alpha \bar{D}_{i} \phi \right ) \; , \\
  \partial_t \bar{\Lambda}^{i} {} = {} & \left[\beta^k \partial_k \bar{\Lambda}^i - \partial_k \beta^i \bar{\Lambda}^k \right] + \bar{\gamma}^{j k} \hat{D}_{j} \hat{D}_{k} \beta^{i} + \frac{2}{3} \Delta^{i} \bar{D}_{j} \beta^{j} + \frac{1}{3} \bar{D}^{i} \bar{D}_{j} \beta^{j} \nonumber \\
  & - 2 \bar{A}^{i j} \left (\partial_{j} \alpha - 6 \partial_{j} \phi \right ) + 2 \alpha \bar{A}^{j k} \Delta_{j k}^{i}  -\frac{4}{3} \alpha \bar{\gamma}^{i j} \partial_{j} K
\end{align}

where the terms moved from the right-hand sides to the left-hand sides are enclosed in square braces.

Notice that the shift advection operator $\beta^k \partial_k \left\{\bar{\gamma}_{i j},\bar{A}_{i j},\phi, K, \bar{\Lambda}^{i}, \alpha, \beta^i, B^i\right\}$ appears on the right-hand side of *every* expression. As the shift determines how the spatial coordinates $x^i$ move on the next 3D slice of our 4D manifold, we find that representing $\partial_k$ in these shift advection terms via an *upwinded* finite difference stencil results in far lower numerical errors. This trick is implemented below in all shift advection terms.

As discussed in the [NRPy+ tutorial notebook on BSSN quantities](Tutorial-BSSN_quantities.ipynb), tensorial expressions can diverge at coordinate singularities, so each tensor in the set of BSSN variables

$$\left\{\bar{\gamma}_{i j},\bar{A}_{i j},\phi, K, \bar{\Lambda}^{i}, \alpha, \beta^i, B^i\right\},$$

is written in terms of the corresponding rescaled quantity in the set

$$\left\{h_{i j},a_{i j},\text{cf}, K, \lambda^{i}, \alpha, \mathcal{V}^i, \mathcal{B}^i\right\},$$

respectively, as defined in the [BSSN quantities tutorial](Tutorial-BSSN_quantities.ipynb).

# Step 2: Time-evolution equations for the BSSN gauge quantities $\alpha$ and $\beta^i$ \[Back to [top](#toc)\]
$$\label{gaugeconditions}$$

As described in the **Background Reading/Lectures** linked to above, the gauge quantities $\alpha$ and $\beta^i$ specify how coordinate time and spatial points adjust from one spatial hypersurface to the next, in our 3+1 decomposition of Einstein's equations.

As choosing $\alpha$ and $\beta^i$ is equivalent to choosing coordinates for where we sample our solution to Einstein's equations, we are completely free to choose $\alpha$ and $\beta^i$ on any given spatial hypersuface. It has been found that fixing $\alpha$ and $\beta^i$ to constant values in the context of dynamical spacetimes results in instabilities, so we generally need to define expressions for $\partial_t \alpha$ and $\partial_t \beta^i$ and couple these equations to the rest of the BSSN time-evolution equations.

Though we are free to choose the form of the right-hand sides of the gauge time evolution equations, very few have been found robust in the presence of (puncture) black holes.

The most commonly adopted gauge conditions for BSSN (i.e., time-evolution equations for the BSSN gauge quantities $\alpha$ and $\beta^i$) are the

* $1+\log$ lapse condition:
$$
\partial_0 \alpha = -2 \alpha K
$$
* Second-order Gamma-driving shift condition:
\begin{align}
\partial_0 \beta^i &= B^{i} \\
\partial_0 B^i &= \frac{3}{4} \partial_{0} \bar{\Lambda}^{i} - \eta B^{i},
\end{align}

where $\partial_0$ is the advection operator; i.e., $\partial_0 A^i = \partial_t A^i - \beta^j \partial_j A^i$. Note that $\partial_{0} \bar{\Lambda}^{i}$ in the right-hand side of the $\partial_{0} B^{i}$ equation is computed by adding $\beta^j \partial_j \bar{\Lambda}^i$ to the right-hand side expression given for $\partial_t \bar{\Lambda}^i$, so no explicit time dependence occurs in the right-hand sides of the BSSN evolution equations and the Method of Lines can be applied directly.

While it is incredibly robust in Cartesian coordinates, [Brown](https://arxiv.org/abs/0902.3652) pointed out that the above time-evolution equation for the shift is not covariant. In fact, we have found this non-covariant version to result in very poor results when solving Einstein's equations in spherical coordinates for a spinning black hole with spin axis pointed in the $\hat{x}$ direction. Therefore we adopt Brown's covariant version as described in the [**full time-evolution equations for the BSSN gauge quantities $\alpha$ and $\beta^i$ tutorial notebook**](Tutorial-BSSN_time_evolution-BSSN_gauge_RHSs.ipynb).

# Step 3: The BSSN constraint equations \[Back to [top](#toc)\]
$$\label{constraintequations}$$

In a way analogous to Maxwell's equations, the BSSN decomposition of Einstein's equations are written as a set of time-evolution equations and a set of constraint equations. In this step we present the BSSN constraints

\begin{align}
\mathcal{H} &= 0 \\
\mathcal{M^i} &= 0,
\end{align}

where $\mathcal{H}=0$ is the **Hamiltonian constraint**, and $\mathcal{M^i} = 0$ is the **momentum constraint**. When constructing our spacetime from the initial data, one spatial hypersurface at a time, to confirm that at a given time, the Hamiltonian and momentum constraint violations converge to zero as expected with increased numerical resolution.

## Step 3.a: The Hamiltonian constraint $\mathcal{H}$ \[Back to [top](#toc)\]
$$\label{hamiltonianconstraint}$$

The Hamiltonian constraint is written (Eq. 13 of [Baumgarte *et al.*](https://arxiv.org/pdf/1211.6632.pdf)):
$$
\mathcal{H} = \frac{2}{3} K^2 - \bar{A}_{ij} \bar{A}^{ij} + e^{-4\phi} \left(\bar{R} - 8 \bar{D}^i \phi \bar{D}_i \phi - 8 \bar{D}^2 \phi\right)
$$

## Step 3.b: The momentum constraint $\mathcal{M}^i$ \[Back to [top](#toc)\]
$$\label{momentumconstraint}$$

The momentum constraint is written (Eq. 47 of [Ruchlin, Etienne, & Baumgarte](https://arxiv.org/pdf/1712.07658.pdf)):

$$    \mathcal{M}^i = e^{-4\phi} \left(
    \frac{1}{\sqrt{\bar{\gamma}}} \hat{D}_j\left(\sqrt{\bar{\gamma}}\bar{A}^{ij}\right) +
    6 \bar{A}^{ij}\partial_j \phi -
    \frac{2}{3} \bar{\gamma}^{ij}\partial_j K +
    \bar{A}^{jk} \Delta\Gamma^i_{jk} + \bar{A}^{ik} \Delta\Gamma^j_{jk}\right)
$$

Notice the momentum constraint as written in [Baumgarte *et al.*](https://arxiv.org/pdf/1211.6632.pdf) is missing a term, as described in [Ruchlin, Etienne, & Baumgarte](https://arxiv.org/pdf/1712.07658.pdf).

# Step 4: The BSSN algebraic constraint: $\hat{\gamma}=\bar{\gamma}$ \[Back to [top](#toc)\]
$$\label{gammaconstraint}$$

[Brown](https://arxiv.org/abs/0902.3652)'s covariant Lagrangian formulation of BSSN, which we adopt, requires that $\partial_t \bar{\gamma} = 0$, where $\bar{\gamma}=\det \bar{\gamma}_{ij}$. We generally choose to set $\bar{\gamma}=\hat{\gamma}$ in our initial data.

Numerical errors will cause $\bar{\gamma}$ to deviate from a constant in time. This actually disrupts the hyperbolicity of the PDEs (causing crashes), so to cure this, we adjust $\bar{\gamma}_{ij}$ at the end of each Runge-Kutta timestep, so that its determinant satisfies $\bar{\gamma}=\hat{\gamma}$ at all times. We adopt the following, rather standard prescription (Eq. 53 of [Ruchlin, Etienne, and Baumgarte (2018)](https://arxiv.org/abs/1712.07658)):

$$
\bar{\gamma}_{ij} \to \left(\frac{\hat{\gamma}}{\bar{\gamma}}\right)^{1/3} \bar{\gamma}_{ij}.
$$
Notice the expression on the right is guaranteed to have determinant equal to $\hat{\gamma}$.

# Step 5: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename
[Tutorial-BSSN_formulation.pdf](Tutorial-BSSN_formulation.pdf) (Note that clicking on this link may not work; you may need to open the PDF file through another means.)

```python
!jupyter nbconvert --to latex --template latex_nrpy_style.tplx --log-level='WARN' Tutorial-BSSN_formulation.ipynb
!pdflatex -interaction=batchmode Tutorial-BSSN_formulation.tex
!pdflatex -interaction=batchmode Tutorial-BSSN_formulation.tex
!pdflatex -interaction=batchmode Tutorial-BSSN_formulation.tex
!rm -f Tut*.out Tut*.aux Tut*.log
```

    [NbConvertApp] Converting notebook Tutorial-BSSN_formulation.ipynb to latex
    [NbConvertApp] Writing 47455 bytes to Tutorial-BSSN_formulation.tex
    This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) (preloaded format=pdflatex)
     restricted \write18 enabled.
    entering extended mode
    This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) (preloaded format=pdflatex)
     restricted \write18 enabled.
    entering extended mode
    This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) (preloaded format=pdflatex)
     restricted \write18 enabled.
    entering extended mode


# 1D Three Wave `GiRaFFEfood` Initial Data for `GiRaFFE`

## This module provides another initial data option for `GiRaFFE`, drawn from [this paper](https://arxiv.org/abs/1310.3274) .

**Notebook Status:**  Validated

**Validation Notes:** This tutorial notebook has been confirmed to be self-consistent with its corresponding NRPy+ module, as documented [below](#code_validation). The initial data has validated against the original `GiRaFFE`, as documented [here](Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy.ipynb).

### NRPy+ Source Code for this module: [GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_1D_tests_three_waves.py](../../edit/in_progress/GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_1D_tests_three_waves.py)

## Introduction:

### Three waves:

This is a flat-spacetime test representing three Alfvén waves (one stationary, one left-going, and one right-going) with initial data
\begin{align}
A_x &= 0 \\
A_y &= 3.5x H(-x) + 3.0x H(x) \\
A_z &= y - 1.5x H(-x) - 3.0x H(x),
\end{align}
where $H(x)$ is the Heaviside function, which generates the magnetic field
$$\mathbf{B}(0,x) = \mathbf{B_a}(0,x) + \mathbf{B_+}(0,x) + \mathbf{B_-}(0,x)$$
and uses the electric field
$$\mathbf{E}(0,x) = \mathbf{E_a}(0,x) + \mathbf{E_+}(0,x) + \mathbf{E_-}(0,x),$$
where subscripted $\mathbf{a}$ corresponds to the stationary wave, subscripted $\mathbf{+}$ corresponds to the right-going wave, and subscripted $\mathbf{-}$ corresponds to the left-going wave, and where
\begin{align}
\mathbf{B_a}(0,x) &= \left \{ \begin{array}{lll} (1.0,1.0,2.0) & \mbox{if} & x<0 \\
					(1.0,1.5,2.0) & \mbox{if} & x>0 \end{array}
\right. , \\
\mathbf{E_a}(0,x) &= \left \{ \begin{array}{lll} (-1.0,1.0,0.0) & \mbox{if} & x<0 \\
					(-1.5,1.0,0.0) & \mbox{if} & x>0 \end{array}
\right.  , \\
\mathbf{B_+}(0,x) &= \left \{ \begin{array}{lll} (0.0,0.0,0.0) & \mbox{if} & x<0 \\
                                        (0.0,1.5,1.0) & \mbox{if} & x>0 \end{array}
\right. , \\
\mathbf{E_+}(0,x) &= \left \{ \begin{array}{lll} (0.0,0.0,0.0) & \mbox{if} & x<0 \\
                                        (0.0,1.0,-1.5) & \mbox{if} & x>0 \end{array}
\right. , \\
\mathbf{B_-}(0,x) &= \left \{ \begin{array}{lll} (0.0,0.5,1.5) & \mbox{if} & x<0 \\
                                        (0.0,0.0,0.0) & \mbox{if} & x>0 \end{array}
\right.  , \\
\mathbf{E_-}(0,x) &= \left \{ \begin{array}{lll} (0.0,-1.5,0.5) & \mbox{if} & x<0 \\
                                        (0.0,0.0,0.0) & \mbox{if} & x>0 \end{array}
\right.  . \\
\end{align}

For the eventual purpose of testing convergence, any quantity $Q$ evolves as $Q(t,x) = Q_a(0,x) + Q_+(0,x-t) + Q_-(0,x+t)$.

See the [Tutorial-GiRaFFEfood_NRPy_Exact_Wald](Tutorial-GiRaFFEfood_NRPy.ipynb) tutorial notebook for more general detail on how this is used.

# Table of Contents:
$$\label{toc}$$

This notebook is organized as follows

1. [Step 1](#initializenrpy): Import core NRPy+ modules and set NRPy+ parameters
1. [Step 2](#vector_ak): Set the vector $A_k$
1. [Step 3](#vectors_for_velocity): Set the vectors $B^i$ and $E^i$ for the velocity
1. [Step 4](#vi): Calculate $v^i$
1. [Step 5](#code_validation): Code Validation against `GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_1D_tests` NRPy+ module
1. [Step 6](#latex_pdf_output): Output this notebook to $\LaTeX$-formatted PDF file

# Step 1: Import core NRPy+ modules and set NRPy+ parameters \[Back to [top](#toc)\]
$$\label{initializenrpy}$$

Here, we will import the NRPy+ core modules and set the reference metric to Cartesian, set commonly used NRPy+ parameters, and set C parameters that will be set from outside the code eventually generated from these expressions. We will also set up a parameter to determine what initial data is set up, although it won't do much yet.

```python
# Step 0: Add NRPy's directory to the path
# https://stackoverflow.com/questions/16780014/import-file-from-parent-directory
import os,sys
nrpy_dir_path = os.path.join("..")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)

# Step 0.a: Import the NRPy+ core modules and set the reference metric to Cartesian
import sympy as sp               # SymPy: The Python computer algebra package upon which NRPy+ depends
import NRPy_param_funcs as par   # NRPy+: Parameter interface
import indexedexp as ixp         # NRPy+: Symbolic indexed expression (e.g., tensors, vectors, etc.) support

import reference_metric as rfm   # NRPy+: Reference metric support
par.set_parval_from_str("reference_metric::CoordSystem","Cartesian")
rfm.reference_metric()

# Step 1a: Set commonly used parameters.
thismodule = "GiRaFFEfood_NRPy_1D"
```

#####

# Step 2: Set the vector $A_k$ \[Back to [top](#toc)\]
$$\label{vector_ak}$$

The vector potential is given as
\begin{align}
A_x &= 0 \\
A_y &= 3.5x H(-x) + 3.0x H(x) \\
A_z &= y - 1.5x H(-x) - 3.0x H(x),
\end{align}

However, to take full advantage of NRPy+'s automated function generation capabilities, we want to write this without the `if` statements, replacing them with calls to `fabs()`. To do so, we will use the NRPy+ module `Min_Max_and_Piecewise_Expressions`.

```python
# We'll use reference_metric.py to define x and y
x = rfm.xxCart[0]
y = rfm.xxCart[1]
```

Now, we can define the vector potential. We will need to write the Heviside function without `if`s, which can easily be done with the module `Min_Max_and_Piecewise_Expressions`. We thus get
$$H(x) = \frac{\max(0,x)}{x}.$$
This implementation is, of course, undefined for $x=0$; this problem is easily solved by adding a very small number (called `TINYDOUBLE` in our implementation) to the denominator (see [Tutorial-Min_Max_and_Piecewise_Expressions](Tutorial-Min_Max_and_Piecewise_Expressions.ipynb) for details on how this works). This is, conveniently, the exact implementation of the `coord_greater_bound()` function!

\begin{align}
A_x &= 0 \\
A_y &= 3.5x H(-x) + 3.0x H(x) \\
A_z &= y - 1.5x H(-x) - 3.0x H(x),
\end{align}

```python
AD = ixp.zerorank1(DIM=3)

import Min_Max_and_Piecewise_Expressions as noif

AD[0] = sp.sympify(0)
AD[1] = sp.Rational(7,2)*x*noif.coord_greater_bound(-x,0) + sp.sympify(3)*x*noif.coord_greater_bound(x,0)
AD[2] = y-sp.Rational(3,2)*x*noif.coord_greater_bound(-x,0) - sp.sympify(3)*x*noif.coord_greater_bound(x,0)
```

# Step 3: Set the vectors $B^i$ and $E^i$ for the velocity \[Back to [top](#toc)\]
$$\label{vectors_for_velocity}$$

First, we will set the the three individual waves; we change all $<$ to $\leq$ to avoid unintented behavior at $x=0$:
\begin{align}
\mathbf{B_a}(0,x) &= \left \{ \begin{array}{lll} (1.0,1.0,2.0) & \mbox{if} & x \leq 0 \\
					(1.0,1.5,2.0) & \mbox{if} & x>0 \end{array}
\right. , \\
\mathbf{E_a}(0,x) &= \left \{ \begin{array}{lll} (-1.0,1.0,0.0) & \mbox{if} & x \leq 0 \\
					(-1.5,1.0,0.0) & \mbox{if} & x>0 \end{array}
\right.  , \\
\mathbf{B_+}(0,x) &= \left \{ \begin{array}{lll} (0.0,0.0,0.0) & \mbox{if} & x \leq 0 \\
                                        (0.0,1.5,1.0) & \mbox{if} & x>0 \end{array}
\right. , \\
\mathbf{E_+}(0,x) &= \left \{ \begin{array}{lll} (0.0,0.0,0.0) & \mbox{if} & x \leq 0 \\
                                        (0.0,1.0,-1.5) & \mbox{if} & x>0 \end{array}
\right. , \\
\mathbf{B_-}(0,x) &= \left \{ \begin{array}{lll} (0.0,0.5,1.5) & \mbox{if} & x \leq 0 \\
                                        (0.0,0.0,0.0) & \mbox{if} & x>0 \end{array}
\right.  , \\
\mathbf{E_-}(0,x) &= \left \{ \begin{array}{lll} (0.0,-1.5,0.5) & \mbox{if} & x \leq 0 \\
                                        (0.0,0.0,0.0) & \mbox{if} & x>0 \end{array}
\right.  . \\
\end{align}

```python
B_aU = ixp.zerorank1(DIM=3)
E_aU = ixp.zerorank1(DIM=3)
B_pU = ixp.zerorank1(DIM=3)
E_pU = ixp.zerorank1(DIM=3)
B_mU = ixp.zerorank1(DIM=3)
E_mU = ixp.zerorank1(DIM=3)

B_aU[0] = sp.sympify(1)
B_aU[1] = noif.coord_leq_bound(x,0) * sp.sympify(1) + noif.coord_greater_bound(x,0) * sp.Rational(3,2)
B_aU[2] = sp.sympify(2)

E_aU[0] = noif.coord_leq_bound(x,0) * sp.sympify(-1) + noif.coord_greater_bound(x,0) * sp.Rational(-3,2)
E_aU[1] = sp.sympify(1)
E_aU[2] = sp.sympify(0)

B_pU[0] = sp.sympify(0)
B_pU[1] = noif.coord_leq_bound(x,0) * sp.sympify(0) + noif.coord_greater_bound(x,0) * sp.Rational(3,2)
B_pU[2] = noif.coord_leq_bound(x,0) * sp.sympify(0) + noif.coord_greater_bound(x,0) * sp.sympify(1)

E_pU[0] = sp.sympify(0)
E_pU[1] = noif.coord_leq_bound(x,0) * sp.sympify(0) + noif.coord_greater_bound(x,0) * sp.sympify(1)
E_pU[2] = noif.coord_leq_bound(x,0) * sp.sympify(0) + noif.coord_greater_bound(x,0) * sp.Rational(-3,2)

B_mU[0] = sp.sympify(0)
B_mU[1] = noif.coord_leq_bound(x,0) * sp.Rational(1,2)  + noif.coord_greater_bound(x,0) * sp.sympify(0)
B_mU[2] = noif.coord_leq_bound(x,0) * sp.Rational(3,2) + noif.coord_greater_bound(x,0) * sp.sympify(0)

E_mU[0] = sp.sympify(0)
E_mU[1] = noif.coord_leq_bound(x,0) * sp.Rational(-3,2) + noif.coord_greater_bound(x,0) * sp.sympify(0)
E_mU[2] = noif.coord_leq_bound(x,0) * sp.Rational(1,2)  + noif.coord_greater_bound(x,0) * sp.sympify(0)
```

Then, we can obtain the total expressions for the magnetic and electric fields by simply adding the three waves together:
\begin{align}
\mathbf{B}(0,x) &= \mathbf{B_a}(0,x) + \mathbf{B_+}(0,x) + \mathbf{B_-}(0,x) \\
\mathbf{E}(0,x) &= \mathbf{E_a}(0,x) + \mathbf{E_+}(0,x) + \mathbf{E_-}(0,x)
\end{align}

```python
BU = ixp.zerorank1(DIM=3)
EU = ixp.zerorank1(DIM=3)
for i in range(3):
    BU[i] = B_aU[i] + B_pU[i] + B_mU[i]
    EU[i] = E_aU[i] + E_pU[i] + E_mU[i]
```

# Step 4: Calculate $v^i$ \[Back to [top](#toc)\]
$$\label{vi}$$

Now, we calculate $$\mathbf{v} = \frac{\mathbf{E} \times \mathbf{B}}{B^2},$$ which is equivalent to $$v^i = [ijk] \frac{E^j B^k}{B^2},$$ where $[ijk]$ is the Levi-Civita symbol and $B^2 = \gamma_{ij} B^i B^j$ is a trivial dot product in flat space.

```python
LeviCivitaSymbolDDD = ixp.LeviCivitaSymbol_dim3_rank3()

B2 = sp.sympify(0)
for i in range(3):
    # In flat spacetime, gamma_{ij} is just a Kronecker delta
    B2 += BU[i]**2 # This is trivial to extend to curved spacetime

ValenciavU = ixp.zerorank1()
for i in range(3):
    for j in range(3):
        for k in range(3):
            ValenciavU[i] += LeviCivitaSymbolDDD[i][j][k] * EU[j] * BU[k] / B2
```

# Step 5: Code Validation against `GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_1D_tests` NRPy+ module \[Back to [top](#toc)\]
$$\label{code_validation}$$

Here, as a code validation check, we verify agreement in the SymPy expressions for the `GiRaFFE` Aligned Rotator initial data equations  we intend to use between
1. this tutorial and
2. the NRPy+ [`GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_1D_tests.py`](../edit/GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_1D_tests.py) module.

```python
import GiRaFFEfood_NRPy.GiRaFFEfood_NRPy_1D_tests_three_waves as gfho
gfho.GiRaFFEfood_NRPy_1D_tests_three_waves()

def consistency_check(quantity1,quantity2,string):
    if quantity1-quantity2==0:
        print(string+" is in agreement!")
    else:
        print(string+" does not agree!")
        sys.exit(1)

print("Consistency check between GiRaFFEfood_NRPy tutorial and NRPy+ module:")

for i in range(3):
    consistency_check(ValenciavU[i],gfho.ValenciavU[i],"ValenciavU"+str(i))
    consistency_check(AD[i],gfho.AD[i],"AD"+str(i))
```

    Consistency check between GiRaFFEfood_NRPy tutorial and NRPy+ module:
    ValenciavU0 is in agreement!
    AD0 is in agreement!
    ValenciavU1 is in agreement!
    AD1 is in agreement!
    ValenciavU2 is in agreement!
    AD2 is in agreement!

# Step 6: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename
[Tutorial-GiRaFFEfood_NRPy_1D_tests.pdf](Tutorial-GiRaFFEfood_NRPy_1D_tests.pdf) (Note that clicking on this link may not work; you may need to open the PDF file through another means.)

```python
import cmdline_helper as cmd    # NRPy+: Multi-platform Python command-line interface
cmd.output_Jupyter_notebook_to_LaTeXed_PDF("Tutorial-GiRaFFEfood_NRPy_1D_tests",location_of_template_file=os.path.join(".."))
```

    pdflatex: security risk: running with elevated privileges
    pdflatex: security risk: running with elevated privileges
    pdflatex: security risk: running with elevated privileges
    pdflatex: security risk: running with elevated privileges
    Created Tutorial-GiRaFFEfood_NRPy_1D_tests.tex, and compiled LaTeX file to
        PDF file Tutorial-GiRaFFEfood_NRPy_1D_tests.pdf


#  CS109B Data Science 2: Advanced Topics in Data Science

## Lab 4 - Bayesian Analysis

**Harvard University**

**Spring 2020**

**Instructors:** Mark Glickman, Pavlos Protopapas, and Chris Tanner

**Lab Instructors:** Chris Tanner and Eleni Angelaki Kaxiras

**Content:** Eleni Angelaki Kaxiras

---

```python
## RUN THIS CELL TO PROPERLY HIGHLIGHT THE EXERCISES
import requests
from IPython.core.display import HTML
styles = requests.get("https://raw.githubusercontent.com/Harvard-IACS/2019-CS109B/master/content/styles/cs109.css").text
HTML(styles)
```

```python
import pymc3 as pm
from pymc3 import summary
```

```python
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as stats
import pandas as pd
%matplotlib inline

import warnings
warnings.filterwarnings('ignore')
```

```python
print('Running on PyMC3 v{}'.format(pm.__version__))
```

    Running on PyMC3 v3.8

```javascript
%%javascript
IPython.OutputArea.auto_scroll_threshold = 20000;
```

    <IPython.core.display.Javascript object>

## Learning Objectives

By the end of this lab, you should be able to:
* Understand how probability distributions work.
* Apply Bayes Rule in calculating probabilities.
* Understand how to apply Bayesian analysis using PyMC3
* Avoid getting fired when talking to your Bayesian employer.

**This lab corresponds to Lectures 6, 7, and 8, and maps to Homework 3.**

## Table of Contents

1. The Bayesian Way of Thinking or Is this a Fair Coin?
2. [Intro to `pyMC3`](#pymc3).
3. [Bayesian Linear Regression](#blr).
4. [Try this at Home: Example on Mining Disasters](#no4).

## 1. The Bayesian way of Thinking

```
Here is my state of knowledge about the situation. Here is some data, I am now going to revise my state of knowledge.
```

Table Exercise: Discuss the statement above with your table mates and make sure everyone understands what it means and what constitutes Bayesian way of thinking. Finally, count the Bayesians among you.

### A. Bayes Rule

\begin{equation}
\label{eq:bayes}
P(A|\textbf{B}) = \frac{P(\textbf{B} |A) P(A) }{P(\textbf{B})}
\end{equation}

$P(A|\textbf{B})$ is the **posterior** distribution, prob(hypothesis | data)

$P(\textbf{B} |A)$ is the **likelihood** function, how probable is my data **B** for different values of the parameters

$P(A)$ is the marginal probability to observe the data, called the **prior**, this captures our belief about the data before observing it.

$P(\textbf{B})$ is the marginal distribution (sometimes called marginal likelihood)

Table Exercise: Solve the Monty Hall Paradox using Bayes Rule.

You are invited to play a game. There are 3 doors behind **one** of which are the keys to a brand new red Tesla. There is a goat behind each of the other two.

You are asked to pick one door, and let's say you pick **Door1**. The host knows where the keys are. Of the two remaining closed doors, he will always open the door that has a goat behind it. He'll say "I will do you a favor and open **Door2**". So he opens Door2 inside which there is, of course, a goat. He now asks you, do you want to open the initial Door you chose or change to **Door3**? Generally, in this game, when you are presented with this choice should you swap the doors?

**Initial Steps:**
- Start by defining the `events` of this probabilities game. One definition is:

    - $A_i$: car is behind door $i$

    - $B_i$ host opens door $i$

$i\in[1,2,3]$

- In more math terms, the question is: is the probability that the price is behind **Door 1** higher than the probability that the price is behind **Door2**, given that an event **has occured**?

### B. Bayes Rule written with Probability Distributions

We have data that we believe come from an underlying distribution of unknown parameters. If we find those parameters, we know everything about the process that generated this data and we can make inferences (create new data).

\begin{equation}
\label{eq:bayes}
P(\theta|\textbf{D}) = \frac{P(\textbf{D} |\theta) P(\theta) }{P(\textbf{D})}
\end{equation}

#### But what is $\theta \;$?

$\theta$ is an unknown yet fixed set of parameters. In Bayesian inference we express our belief about what $\theta$ might be and instead of trying to guess $\theta$ exactly, we look for its **probability distribution**. What that means is that we are looking for the **parameters** of that distribution. For example, for a Poisson distribution our $\theta$ is only $\lambda$. In a normal distribution, our $\theta$ is often just $\mu$ and $\sigma$.

### C. A review of Common Probability Distributions

#### Discrete Distributions

The random variable has a **probability mass function (pmf)** which measures the probability that our random variable will take a specific value $y$, denoted $P(Y=y)$.

- **Bernoulli** (binary outcome, success has probability $\theta$, $one$ trial):
$
P(Y=k) =  \theta^k(1-\theta)^{1-k}
$

- **Binomial** (binary outcome, success has probability $\theta$, $n$ trials):
\begin{equation}
P(Y=k) =  {{n}\choose{k}} \cdot \theta^k(1-\theta)^{n-k}
\end{equation}

*Note*: Binomial(1,$p$) = Bernouli($p$)

- **Negative Binomial**

- **Poisson** (counts independent events occurring at a rate)
\begin{equation}
P\left( Y=y|\lambda \right) = \frac{{e^{ - \lambda } \lambda ^y }}{{y!}}
\end{equation}
y = 0,1,2,...

- **Discrete Uniform**

- **Categorical, or Multinulli** (random variables can take any of K possible categories, each having its own probability; this is a generalization of the Bernoulli distribution for a discrete variable with more than two possible outcomes, such as the roll of a die)

- **Dirichlet-multinomial** (a generalization of the beta distribution for many variables)

#### Continuous Distributions

The random variable has a **probability density function (pdf)**.
- **Uniform** (variable equally likely to be near each value in interval $(a,b)$)
\begin{equation}
P(X = x) = \frac{1}{b - a}
\end{equation}
anywhere within the interval $(a, b)$, and zero elsewhere.

- **Normal** (a.k.a. Gaussian)
\begin{equation}
X \sim  \mathcal{N}(\mu,\,\sigma^{2})
\end{equation}

    A Normal distribution can be parameterized either in terms of precision $\tau$ or standard deviation ($\sigma^{2}$. The link between the two is given by
\begin{equation}
\tau = \frac{1}{\sigma^{2}}
\end{equation}
 - Mean $\mu$
 - Variance $\frac{1}{\tau}$ or $\sigma^{2}$
 - Parameters: `mu: float`, `sigma: float` or `tau: float`

- **Beta** (variable ($\theta$) taking on values in the interval $[0,1]$, and parametrized by two positive parameters, $\alpha$ and $\beta$ that control the shape of the distribution.

*Note:*Beta is a good distribution to use for priors (beliefs) because its range is $[0,1]$ which is the natural range for a probability and because we can model a wide range of functions by changing the $\alpha$ and $\beta$ parameters.

\begin{equation}
\label{eq:beta}
P(\theta) = \frac{1}{B(\alpha, \beta)} {\theta}^{\alpha - 1} (1 - \theta)^{\beta - 1} \propto {\theta}^{\alpha - 1} (1 - \theta)^{\beta - 1}
\end{equation}

where the normalisation constant, $B$, is a beta function of $\alpha$ and $\beta$,

\begin{equation}
B(\alpha, \beta) = \int_{t=0}^1 t^{\alpha - 1} (1 - t)^{\beta - 1} dt.
\end{equation}

- **Exponential**

- **Gamma**

 #### Code Resources:
 - Statistical Distributions in numpy/scipy: [scipy.stats](https://docs.scipy.org/doc/scipy/reference/stats.html)
 - Statistical Distributions in pyMC3: [distributions in PyMC3](https://docs.pymc.io/api/distributions.html) (we will see those below).

Exercise: Plot a Discrete variable

Change the value of $\mu$ in the Poisson PMF and see how the plot changes. Remember that the y-axis in a discrete probability distribution shows the probability of the random variable having a specific value in the x-axis.

\begin{equation}
P\left( X=k \right) = \frac{{e^{ - \mu } \mu ^k }}{{k!}}
\end{equation}

**stats.poisson.pmf(x, mu)** $\mu$(mu) is our $\theta$ in this case.

```python
plt.style.use('seaborn-darkgrid')
x = np.arange(0, 30)
for m in [0.5, 3, 8]:
    pmf = stats.poisson.pmf(x, m)
    plt.plot(x, pmf, 'o', alpha=0.5, label='$\mu$ = {}'.format(m))
plt.xlabel('random variable', fontsize=12)
plt.ylabel('probability', fontsize=12)
plt.legend(loc=1)
plt.ylim=(-0.1)
plt.show()
```

```python
# same for binomial
plt.style.use('seaborn-darkgrid')
x = np.arange(0, 22)
ns = [10, 17]
ps = [0.5, 0.7]
for n, p in zip(ns, ps):
    pmf = stats.binom.pmf(x, n, p)
    plt.plot(x, pmf, 'o', alpha=0.5, label='n = {}, p = {}'.format(n, p))
plt.xlabel('x', fontsize=14)
plt.ylabel('f(x)', fontsize=14)
plt.legend(loc=1)
plt.show()
```

```python
# discrete uniform
plt.style.use('seaborn-darkgrid')
ls = [0]
us = [3] # watch out, this number can only be integer!
for l, u in zip(ls, us):
    x = np.arange(l, u+1)
    pmf = [1.0 / (u - l + 1)] * len(x)
    plt.plot(x, pmf, '-o', label='lower = {}, upper = {}'.format(l, u))
plt.xlabel('x', fontsize=12)
plt.ylabel('probability P(x)', fontsize=12)
plt.legend(loc=1)
plt.show()
```

Exercise: Plot a continuous variable

Change the value of $\mu$ in the Uniform PDF and see how the plot changes.

Remember that the y-axis in a continuous probability distribution does not shows the actual probability of the random variable having a specific value in the x-axis because that probability is zero!. Instead, to see the probability that the variable is within a small margin we look at the integral below the curve of the PDF.

The uniform is often used as a noninformative prior.

```
Uniform - numpy.random.uniform(a=0.0, b=1.0, size)
```

$\alpha$ and $\beta$ are our parameters. `size` is how many tries to perform.
Our $\theta$ is basically the combination of the parameters a,b. We can also call it
\begin{equation}
\mu = (a+b)/2
\end{equation}

```python
from scipy.stats import uniform

r = uniform.rvs(size=1000)
plt.plot(r, uniform.pdf(r),'r-', lw=5, alpha=0.6, label='uniform pdf')
plt.hist(r, density=True, histtype='stepfilled', alpha=0.2)
plt.ylabel(r'probability density')
plt.xlabel(f'random variable')
plt.legend(loc='best', frameon=False)
plt.show()
```

```python
from scipy.stats import beta

alphas = [0.5, 1.5, 3.0]
betas = [0.5, 1.5, 3.0]
x = np.linspace(0, 1, 1000)
colors = ['red', 'green', 'blue']

fig, ax = plt.subplots(figsize=(8, 5))

for a, b, colors in zip(alphas, betas, colors):
    dist = beta(a, b)
    plt.plot(x, dist.pdf(x), c=colors,
             label=f'a={a}, b={b}')

ax.set_ylim(0, 3)

ax.set_xlabel(r'$\theta$')
ax.set_ylabel(r'$p(\theta|\alpha,\beta)$')
ax.set_title('Beta Distribution')

ax.legend(loc='best')
fig.show();
```

```python
plt.style.use('seaborn-darkgrid')
x = np.linspace(-5, 5, 1000)
mus = [0., 0., 0., -2.]
sigmas = [0.4, 1., 2., 0.4]
for mu, sigma in zip(mus, sigmas):
    pdf = stats.norm.pdf(x, mu, sigma)
    plt.plot(x, pdf, label=r'$\mu$ = '+ f'{mu},' + r'$\sigma$ = ' + f'{sigma}')
plt.xlabel('random variable', fontsize=12)
plt.ylabel('probability density', fontsize=12)
plt.legend(loc=1)
plt.show()
```

```python
plt.style.use('seaborn-darkgrid')
x = np.linspace(-5, 5, 1000)
mus = [0., 0., 0., -2.] # mean
sigmas = [0.4, 1., 2., 0.4] # std
for mu, sigma in zip(mus, sigmas):
      plt.plot(x, uniform.pdf(x, mu, sigma), lw=5, alpha=0.4, \
               label=r'$\mu$ = '+ f'{mu},' + r'$\sigma$ = ' + f'{sigma}')
plt.xlabel('random variable', fontsize=12)
plt.ylabel('probability density', fontsize=12)
plt.legend(loc=1)
plt.show()
```

### D. Is this a Fair Coin?

We do not want to promote gambling but let's say you visit the casino in **Monte Carlo**. You want to test your theory that casinos are dubious places where coins have been manipulated to have a larger probability for tails. So you will try to estimate how fair a coin is based on 100 flips.

You begin by flipping the coin. You get either Heads ($H$) or Tails ($T$) as our observed data and want to see if your posterior probabilities change as you obtain more data, that is, more coin flips. A nice way to visualize this is to plot the posterior probabilities as we observe more flips (data).

We will be using Bayes rule. $\textbf{D}$ is our data.

\begin{equation}
\label{eq:bayes}
P(\theta|\textbf{D}) = \frac{P(\textbf{D} |\theta) P(\theta) }{P(\textbf{D})}
\end{equation}

In the case of a coin toss when we observe $k$ heads in $n$ tosses:
\begin{equation}
\label{eq:bayes}
P(\theta|\textbf{k}) = Beta(\alpha + \textbf{k}, \beta + n - \textbf{k})
\end{equation}

we can say that $\alpha$ and $\beta$ play the roles of a "prior number of heads" and "prior number of tails".

```python
# play with the priors - here we manually set them but we could be sampling from a separate Beta
trials = np.array([0, 1, 3, 5, 10, 15, 20, 100, 200, 300])
heads = np.array([0, 1, 2, 4, 8, 10, 10, 50, 180, 150])
x = np.linspace(0, 1, 100)

# for simplicity we set a,b=1

plt.figure(figsize=(10,8))
for k, N in enumerate(trials):
    sx = plt.subplot(len(trials)/2, 2, k+1)
    posterior = stats.beta.pdf(x, 1 + heads[k], 1 + trials[k] - heads[k])
    plt.plot(x, posterior, alpha = 0.5, label=f'{trials[k]} tosses\n {heads[k]} heads');
    plt.fill_between(x, 0, posterior, color="#348ABD", alpha=0.4)
    plt.legend(loc='upper left', fontsize=10)
    plt.legend()
    plt.autoscale(tight=True)

plt.suptitle("Posterior probabilities for coin flips", fontsize=15);
plt.tight_layout()
plt.subplots_adjust(top=0.88)
```

 [Top](#top)

## 2. Introduction to `pyMC3`

PyMC3 is a Python library for programming Bayesian analysis, and more specifically, data creation, model definition, model fitting, and posterior analysis. It uses the concept of a `model` which contains assigned parametric statistical distributions to unknown quantities in the model. Within models we define random variables and their distributions. A distribution requires at least a `name` argument, and other `parameters` that define it. You may also use the `logp()` method in the model to build the model log-likelihood function. We define and fit the model.

PyMC3 includes a comprehensive set of pre-defined statistical distributions that can be used as model building blocks. Although they are not meant to be used outside of a `model`, you can invoke them by using the prefix `pm`, as in `pm.Normal`.

#### Markov Chain Monte Carlo (MCMC) Simulations

PyMC3 uses the **No-U-Turn Sampler (NUTS)** and the **Random Walk Metropolis**, two Markov chain Monte Carlo (MCMC) algorithms for sampling in posterior space. Monte Carlo gets into the name because when we sample in posterior space, we choose our next move via a pseudo-random process. NUTS is a sophisticated algorithm that can handle a large number of unknown (albeit continuous) variables.

```python
with pm.Model() as model:
    z = pm.Normal('z', mu=0., sigma=5.)
    x = pm.Normal('x', mu=z, sigma=1., observed=5.)
print(x.logp({'z': 2.5}))
print(z.random(10, 100)[:10])
```

    -4.043938533204672
    [ 2.49487126 -0.51068451 -3.65709456  0.43477628 10.22323769  1.22927069
      0.01286762 -4.87182774 -2.60094774 -0.72939632]

**References**:

- *Salvatier J, Wiecki TV, Fonnesbeck C. 2016. Probabilistic programming in Python using PyMC3. PeerJ Computer Science 2:e55* [(https://doi.org/10.7717/peerj-cs.55)](https://doi.org/10.7717/peerj-cs.55)
- [Distributions in PyMC3](https://docs.pymc.io/api/distributions.html)
- [More Details on Distributions](https://docs.pymc.io/developer_guide.html)

Information about PyMC3 functions including descriptions of distributions, sampling methods, and other functions, is available via the `help` command.

```python
#help(pm.Poisson)
```

 [Top](#top)

## 3. Bayesian Linear Regression

Let's say we want to predict outcomes Y as normally distributed observations with an expected value $mu$ that is a linear function of two predictor variables, $\bf{x}_1$ and $\bf{x}_2$.

\begin{equation}
\mu = \alpha + \beta_1 \bf{x}_1 + \beta_2 x_2
\end{equation}

\begin{equation}
Y \sim  \mathcal{N}(\mu,\,\sigma^{2})
\end{equation}

where $\sigma^2$ represents the measurement error.

In this example, we will use $\sigma^2 = 10$

We also choose the parameters as normal distributions:

\begin{eqnarray}
\alpha \sim  \mathcal{N}(0,\,10) \\
\beta_i \sim  \mathcal{N}(0,\,10) \\
\sigma^2 \sim  |\mathcal{N}(0,\,10)|
\end{eqnarray}

We will artificially create the data to predict on. We will then see if our model predicts them correctly.

```python
# Initialize random number generator
np.random.seed(123)

# True parameter values
alpha, sigma = 1, 1
beta = [1, 2.5]

# Size of dataset
size = 100

# Predictor variable
X1 = np.linspace(0, 1, size)
X2 = np.linspace(0,.2, size)

# Simulate outcome variable
Y = alpha + beta[0]*X1 + beta[1]*X2 + np.random.randn(size)*sigma

fig, ax = plt.subplots(1,2, figsize=(10,6), sharex=True)
ax[0].scatter(X1,Y)
ax[1].scatter(X2,Y)
ax[0].set_xlabel(r'$x_1$', fontsize=14)
ax[0].set_ylabel(r'$Y$', fontsize=14)
ax[1].set_xlabel(r'$x_2$', fontsize=14)
ax[1].set_ylabel(r'$Y$', fontsize=14)
```

```python
from pymc3 import Model, Normal, HalfNormal

basic_model = Model()

with basic_model:

    # Priors for unknown model parameters, specifically create stochastic random variables
    # with Normal prior distributions for the regression coefficients,
    # and a half-normal distribution for the standard deviation of the observations, σ.
    alpha = Normal('alpha', mu=0, sd=10)
    beta = Normal('beta', mu=0, sd=10, shape=2)
    sigma = HalfNormal('sigma', sd=1)

    # Expected value of outcome - posterior
    mu = alpha + beta[0]*X1 + beta[1]*X2

    # Likelihood (sampling distribution) of observations
    Y_obs = Normal('Y_obs', mu=mu, sd=sigma, observed=Y)
```

```python
# model fitting with sampling
from pymc3 import NUTS, sample, find_MAP
from scipy import optimize

with basic_model:

    # obtain starting values via MAP
    start = find_MAP(fmin=optimize.fmin_powell)

    # instantiate sampler
    step = NUTS(scaling=start)

    # draw 2000 posterior samples
    trace = sample(2000, step, start=start)
```

    logp = -164.5:   5%|▌         | 270/5000 [00:00<00:01, 4530.74it/s]

    Optimization terminated successfully.
             Current function value: 164.496957
             Iterations: 6
             Function evaluations: 271

    logp = -164.5:   5%|▌         | 271/5000 [00:00<00:12, 391.82it/s]
    Multiprocess sampling (4 chains in 4 jobs)
    NUTS: [sigma, beta, alpha]
    Sampling 4 chains, 0 divergences: 100%|██████████| 10000/10000 [00:08<00:00, 1114.21draws/s]
    The number of effective samples is smaller than 25% for some parameters.

```python
from pymc3 import traceplot

traceplot(trace);
```

```python
results = pm.summary(trace,
                     var_names=['alpha', 'beta', 'sigma'])
results
```

      mean
      sd
      hpd_3%
      hpd_97%
      mcse_mean
      mcse_sd
      ess_mean
      ess_sd
      ess_bulk
      ess_tail
      r_hat

      alpha
      1.015
      0.232
      0.580
      1.452
      0.004
      0.003
      4153.0
      4094.0
      4147.0
      4245.0
      1.0

      beta[0]
      1.572
      2.028
      -2.210
      5.334
      0.062
      0.046
      1069.0
      956.0
      1077.0
      1131.0
      1.0

      beta[1]
      -0.234
      9.916
      -18.657
      18.059
      0.301
      0.213
      1085.0
      1085.0
      1093.0
      1210.0
      1.0

      sigma
      1.147
      0.080
      1.003
      1.303
      0.001
      0.001
      7060.0
      6958.0
      7181.0
      5055.0
      1.0

This linear regression example is from the original paper on PyMC3: *Salvatier J, Wiecki TV, Fonnesbeck C. 2016. Probabilistic programming in Python using PyMC3. PeerJ Computer Science 2:e55 https://doi.org/10.7717/peerj-cs.55*

 [Top](#top)

## 4. Try this at Home: Example on Mining Disasters
We will go over the classical `mining disasters from 1851 to 1962` dataset.

This example is from the [pyMC3 Docs](https://docs.pymc.io/notebooks/getting_started.html).

```python
import pandas as pd
disaster_data = pd.Series([4, 5, 4, 0, 1, 4, 3, 4, 0, 6, 3, 3, 4, 0, 2, 6,
                           3, 3, 5, 4, 5, 3, 1, 4, 4, 1, 5, 5, 3, 4, 2, 5,
                           2, 2, 3, 4, 2, 1, 3, np.nan, 2, 1, 1, 1, 1, 3, 0, 0,
                           1, 0, 1, 1, 0, 0, 3, 1, 0, 3, 2, 2, 0, 1, 1, 1,
                           0, 1, 0, 1, 0, 0, 0, 2, 1, 0, 0, 0, 1, 1, 0, 2,
                           3, 3, 1, np.nan, 2, 1, 1, 1, 1, 2, 4, 2, 0, 0, 1, 4,
                           0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1])
fontsize = 12
years = np.arange(1851, 1962)
plt.figure(figsize=(10,5))
#plt.scatter(years, disaster_data);
plt.bar(years, disaster_data)
plt.ylabel('Disaster count', size=fontsize)
plt.xlabel('Year', size=fontsize);
plt.title('Was there a Turning Point in Mining disasters from 1851 to 1962?', size=15);
```

#### Building the model

**Step1:** We choose the probability model for our experiment. Occurrences of disasters in the time series is thought to follow a **Poisson** process with a large **rate** parameter in the early part of the time series, and from one with a smaller **rate** in the later part. We are interested in locating the change point in the series, which perhaps is related to changes in mining safety regulations.

```
disasters = pm.Poisson('disasters', rate, observed=disaster_data)
```

We have two rates, `early_rate` if $t<=s$, and `late_rate` if $t>s$, where $s$ is the year the switch was made (a.k.a. the `switchpoint`).

**Step2:** Choose a prior distributions of the two rates, what we believe the rates were before we observed the data, and the switchpoint. We choose Exponential.
```
early_rate = pm.Exponential('early_rate', 1)
```

The parameters of this model are:

**Note:** Watch for missing values. Missing values are handled transparently by passing a MaskedArray or a pandas.DataFrame. Behind the scenes, another random variable, disasters.missing_values is created to model the missing values. If you pass a np.array with missing values you will get an error.

```python
with pm.Model() as disaster_model:

    # discrete
    switchpoint = pm.DiscreteUniform('switchpoint', lower=years.min(), upper=years.max(), testval=1900)

    # Priors for pre- and post-switch rates number of disasters
    early_rate = pm.Exponential('early_rate', 1)
    late_rate = pm.Exponential('late_rate', 1)

    # our theta - allocate appropriate Poisson rates to years before and after current
    # switch is an `if` statement in puMC3
    rate = pm.math.switch(switchpoint >= years, early_rate, late_rate)

    # our observed data as a likelihood function of the `rate` parameters
    # shows how we think our data is distributed
    disasters = pm.Poisson('disasters', rate, observed=disaster_data)
```

#### Model Fitting

```python
# there are defaults but we can also more explicitly set the sampling algorithms
with disaster_model:

    # for continuous variables
    step1 = pm.NUTS([early_rate, late_rate])

    # for discrete variables
    step2 = pm.Metropolis([switchpoint, disasters.missing_values[0]] )

    trace = pm.sample(10000, step=[step1, step2])
    # try different number of samples
    #trace = pm.sample(5000, step=[step1, step2])
```

    Multiprocess sampling (4 chains in 4 jobs)
    CompoundStep
    >NUTS: [late_rate, early_rate]
    >CompoundStep
    >>Metropolis: [disasters_missing]
    >>Metropolis: [switchpoint]
    Sampling 4 chains, 0 divergences:  75%|███████▌  | 31500/42000 [00:30<00:04, 2506.23draws/s]

#### Posterior Analysis

On the left side plots we notice that our early rate is between 2.5 and 3.5 disasters a year. In the late period it seems to be between 0.6 and 1.2 so definitely lower.

The right side plots show the samples we drew to come to our conclusion.

```python
pm.traceplot(trace, ['early_rate', 'late_rate', 'switchpoint'], figsize=(20,10));
```

```python
results = pm.summary(trace,
                     var_names=['early_rate', 'late_rate', 'switchpoint'])
results
```