## Subsurface Data Analytics

### Confidence Intervals and Hypothesis Testing for Subsurface Data Analytics in Python

#### Michael Pyrcz, Associate Professor, University of Texas at Austin

##### [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1)

##### Reporting Uncertainty and Significance

With confidence intervals and hypothesis testing we have the opportunity to report uncertainty and to report significance in our statistics.

* report **uncertainty and significance** with our results

This is a tutorial / demonstration of **Confidence Intervals and Hypothesis Testing in Python** for Subsurface Modeling.  In Python, the SciPy package, specifically the Stats functions (https://docs.scipy.org/doc/scipy/reference/stats.html) provide excellent tools for efficient use of statistics.

This tutorial includes basic, typical confidence interval and hypothesis testing methods that would commonly be required for Engineers and Geoscientists including:

1. Student-t confidence interval for the mean
2. Student-t hypothesis test for difference in means (pooled variance)
3. Student-t hypothesis test for difference in means (difference variances), Welch's t Test
3. F-distribution hypothesis test for difference in variances

#### Additional Resources

These workflows are based on standard methods with their associated limitations and assumptions. For more information see:

* [Confidence Intervals Lecture](https://www.youtube.com/watch?v=oaXCcTWcU04)

* [Hypothesis Testing_Lecture](https://www.youtube.com/watch?v=rvt9UM148tQ)

* Also, see the lecture and workflow on Bootstrap https://git.io/fhgUW for a general, empirical approach to assess uncertianty in statistics.

I have provided various workflows for subsurface data analytics, geostatistics and machine learning:

* [Python](https://git.io/fh4eX)

* [Excel](https://github.com/GeostatsGuy/LectureExercises/blob/master/Lecture7_CI_Hypoth_eg_R.xlsx)
* [R](https://github.com/GeostatsGuy/LectureExercises/blob/master/Lecture7_CI_Hypoth_eg.R)

and all of my University of Texas at Austin

* [Lectures](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig/featured?view_as=subscriber)

##### Caveats

I have not included all the details, specifically the test assumptions in this document.  These are included in the accompanying course notes, recorded lectures listed above.

#### Workflow Goal

0. Introduction to Python in Jupyter including setting a working directory, loading data into a Pandas DataFrame.
1. Learn the basics for working with confidence intervals and hypothesis testing in Python.
2. Demonstrate the efficiency of using Python and SciPy package for statistical analysis.
3. Learn how to quantify uncertainty and significance in samples.

#### Objective

I want to provide hands-on experience with building subsurface modeling workflows. Python provides an excellent vehicle to accomplish this. I have coded a package called GeostatsPy with GSLIB: Geostatistical Library (Deutsch and Journel, 1998) functionality that provides basic building blocks for building subsurface modeling workflows.

The objective is to remove the hurdles of subsurface modeling workflow construction by providing building blocks and sufficient examples. This is not a coding class per se, but we need the ability to 'script' workflows working with numerical methods.

#### Getting Started

Here's the steps to get setup in Python with the GeostatsPy package:

1. Install Anaconda 3 on your machine (https://www.anaconda.com/download/).
2. From Anaconda Navigator (within Anaconda3 group), go to the environment tab, click on base (root) green arrow and open a terminal.
3. In the terminal type: pip install geostatspy.
4. Open Jupyter and in the top block get started by copy and pasting the code block below from this Jupyter Notebook to start using the geostatspy functionality.

You will need to copy the data file to your working directory.  They are available here:

* Tabular data - PorositySample2Units.csv at https://git.io/fhrM8

#### Load the required libraries

The following code loads the required libraries.

```python
import geostatspy.GSLIB as GSLIB                       # GSLIB utilies, visualization and wrapper
import geostatspy.geostats as geostats                 # GSLIB methods convert to Python
```

We will also need some standard packages. These should have been installed with Anaconda 3.

```python
import os                                              # to set current working directory
import numpy as np                                     # arrays and matrix math
import scipy.stats as st                               # statistical methods
import pandas as pd                                    # DataFrames
import matplotlib.pyplot as plt                        # plotting
```

If you get a package import error, you may have to first install some of these packages. This can usually be accomplished by opening up a command window on Windows and then typing 'python -m pip install [package-name]'. More assistance is available with the respective package docs.

#### Set the working directory

I always like to do this so I don't lose files and to simplify subsequent read and writes (avoid including the full address each time).  Also, in this case make sure to place the required (see below) data file in this directory.  When we are done with this tutorial we will write our new dataset back to this directory.

```python
os.chdir("C:\PGE337")                                  # set the working directory
```

#### Loading Data

Let's load the provided dataset. 'PorositySamples2Units.csv' is available at https://github.com/GeostatsGuy/GeoDataSets. It is a comma delimited file with 20 porosity measures from 2 rock units from the subsurface:

* porosity (fraction)

We load it with the pandas 'read_csv' function into a data frame we called 'df' and then preview it by printing a slice and by utilizing the 'head' DataFrame member function (with a nice and clean format, see below).

We load it with the pandas 'read_csv' function into a data frame we called 'df' and then preview it by printing a slice and by utilizing the 'head' DataFrame member function (with a nice and clean format, see below).

**Python Tip: using functions from a package** just type the label for the package that we declared at the beginning:

```python
import pandas as pd
```

so we can access the pandas function 'read_csv' with the command:

```python
pd.read_csv()
```

but read csv has required input parameters. The essential one is the name of the file. For our circumstance all the other default parameters are fine. If you want to see all the possible parameters for this function, just go to the docs [here](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_csv.html).

* The docs are always helpful
* There is often a lot of flexibility for Python functions, possible through using various inputs parameters

also, the program has an output, a pandas DataFrame loaded from the data.  So we have to specficy the name / variable representing that new object.

```python
df = pd.read_csv("PorositySample2Units.csv")
```

Let's run this command to load the data and then look at the resulting DataFrame to ensure that we loaded it.

```python
df = pd.read_csv("PorositySample2Units.csv")           # read a .csv file in as a DataFrame
df = df.rename(columns={'X1': 'Unit1_Por','X2':'Unit2_Por'})   # rename variables for clarity
#print(df.iloc[0:5,:])                                 # display first 4 samples in the table as a preview
df.head()                                              # we could also use this command for a table preview
```

      Unit1_Por
      Unit2_Por

      0
      0.21
      0.20

      1
      0.17
      0.26

      2
      0.15
      0.20

      3
      0.20
      0.19

      4
      0.19
      0.13

#### Summary Statistics

It is useful to review the summary statistics of our loaded DataFrame.  That can be accomplished with the 'describe' DataFrame member function.  We transpose to switch the axes for ease of visualization.

```python
df.describe().transpose()                              # visualize summary statistics
```

      count
      mean
      std
      min
      25%
      50%
      75%
      max

      Unit1_Por
      20.0
      0.1645
      0.027810
      0.11
      0.1500
      0.17
      0.19
      0.21

      Unit2_Por
      20.0
      0.2000
      0.045422
      0.11
      0.1675
      0.20
      0.23
      0.30

##### Extract Porosity from Well1 and Well2

Here we extract the X1 and X2 unit porosity samples from the DataFrame into separate arrays called 'Por1' and 'Por2' for convenience.

**Python Tip: extracting data from DataFrames**

We can extract the data for a single feature with this command:

```python
1D_series = df['feature_name']
```

The 'series' retains information about the feature that was included in the DataFrame including the name and the indexing. This is fine, but some methods don't work with series so we can also extract the data as a 1D ndarray.  By adding the '.values' the series is converted to a 1D array.

```python
1D_ndarray = df['feature_name'].values
```

So

```python
por1 = df['Unit1_Por'].values                                 # extract well 1 porosity to a ndarray
por2 = df['Unit2_Por'].values                                 # extract well 2 porosity to a ndarray
```

We could also work with the DataFrame directly and just use this command everytime we use a function that requires a ndarray.  For example:

```python
np.average(df['Unit1_Por'].values)
```

would return the average over unit 1.  I like to extract the ndarray so we can use more compact code:

```python
np.average(por)
```

to get the same result.

**Coding Tip** the ndarrays are **shallow copies**, that means that if you change the array the DataFrame will also be changed. In other words that DataFrame and ndarray remained linked together.

* you can modify either the ndarray or DataFrame and they remain consistent

#### Well Distributions

We should first visualize the distributions.  It is convenient to plot the distributions on top of each other for comparision.

**Coding Tip** this is our first time to use MatplotLib.  This exaple below makes the binned histogram and cumulative distribution function for the unit 1 and 2 porosity measures.

* we use $plt$ to access the matplotlib.plotly functionality as imported above

* after we declare a subplot all commands append and modify to the current plot

* subplot(number of rows, number of columns, plot index) is the format

```python
plt.subplot(121)                # the first plot for a 1 x 2 matrix of plots
```

* we superimpose two histograms on top of eachother with different colours and transparency

```python
plt.hist(por1, facecolor='red',bins=np.linspace(0.0,0.4,20),alpha=0.2,density=False,edgecolor='black',label='Unit 1')
plt.hist(por2, facecolor='blue',bins=np.linspace(0.0,0.4,20),alpha=0.2,density=False,edgecolor='black',label = 'Unit 2')
```

* then we add labels, set the axes, and include a legend

Later on will will use the histogram function from reimplimented in geostatspy from GSLIB for simple, easy histograms.

```python
plt.subplot(121)
plt.hist(por1, facecolor='red',bins=np.linspace(0.0,0.4,20),alpha=0.2,density=False,edgecolor='black',label='Unit 1')
plt.hist(por2, facecolor='blue',bins=np.linspace(0.0,0.4,20),alpha=0.2,density=False,edgecolor='black',label = 'Unit 2')
plt.xlim([0.0,0.4]); plt.ylim([0,8.0])
plt.xlabel('Porosity (fraction)'); plt.ylabel('Frequency'); plt.title('Porosity Unit 1 and 2')
plt.legend(loc='upper left')
plt.grid(True)

plt.subplot(122)
plt.hist(por1, facecolor='red',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.2,density=True,cumulative=True,edgecolor='black',label='Unit 1')
plt.hist(por2, facecolor='blue',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.2,density=True,cumulative=True,edgecolor='black',label='Unit 2')
plt.xlim([0.0,0.4]); plt.ylim([0,1.0])
plt.xlabel('Porosity (fraction)'); plt.ylabel('Cumulative Probability'); plt.title('Porosity Unit 1 and 2')
plt.legend(loc='upper left')
plt.grid(True)

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.3)
plt.show()
```

The histogram and cumulative distribution functions indicate the porosity distributions in both wells 1 and 2. Ocular inspection does indicate:

1. not a lot of samples are available for each (n=20)
2. the distributions look different

Let's use statistics to learn more about the subsurface from these two wells.

#### Confidence Intervals

Let's first demonstrate the calculation of the confidence interval for the sample mean at a 95% confidence level.  This could be interpreted as the interval over which there is a 95% confidence that it contains the true population mean.  We use the student's t distribution as we assume we do not know the variance and the sample size is small.

\begin{equation}
x̅ \pm t_{\frac{\alpha}{2},n-1} \times \frac {s}{\sqrt{n}}
\end{equation}

**Coding Tip** it is easy to combine text, variables and equations in printed output.

* we just need to use the '+' to concatenate (add) the values and the 'str()' command to convert the values to strings.

For example:

```python
print('The average porosity is ' + str(np.average(por1)) + '.')
```

```python
ci_95_por1 = st.t.interval(alpha = 0.9, df = len(df)-1, loc=np.mean(por1), scale=st.sem(por1))
print('The confidence interval for the Well 1 mean porosity is ' + str(round(np.mean(por1),3)) + ' +/- ' + str(round(ci_95_por1[1]-np.mean(por1),3))
      + ', with a range of ' + str(round(ci_95_por1[0],3)) + ', ' + str(round(ci_95_por1[1],3)))

ci_95_por2 = st.t.interval(alpha = 0.9, df = len(df)-1, loc=np.mean(por2), scale=st.sem(por2))
print('The confidence interval for the Well 2 mean porosity is ' + str(round(np.mean(por2),3)) + ' +/- ' + str(round(ci_95_por2[1]-np.mean(por2),3))
      + ', with a range of ' + str(round(ci_95_por2[0],3)) + ', ' + str(round(ci_95_por2[1],3)))
```

    The confidence interval for the Well 1 mean porosity is 0.164 +/- 0.011, with a range of 0.154, 0.175
    The confidence interval for the Well 2 mean porosity is 0.2 +/- 0.018, with a range of 0.182, 0.218

So with one line of code we got the confidence interval in the mean with the Student's distribution!

```python
ci_95_por1 = st.t.interval(alpha = 0.9, df = len(df)-1, loc=np.mean(por1), scale=st.sem(por1))
```
where:

* alpha is the significance level (alpha should be 1 - significance)

* df is the degrees of freedom

* loc is the sample mean calculated with the numpy.mean of an array function

* scale is the stand error of the mean calculated with the scipy.stats.sem function

The output is an array with:

```python
ci_95_por2[0]                            # lower bound
ci_95_por2[1]                            # upper bound
```

#### Using Confidence Intervals to Build an Uncertainty Model for by-Well Distributions

The confidence interval for the mean could be applied directly as an uncertainty model! The uncertainty in mean porosity at each well could be applied to calculate the uncertainty in the mean porosity applied to model porosity around the two wells. This method would account for the limited number of porosity samples available. If more porosity samples we available then this uncertainty would decrease (note the $\sqrt{n}$ in the denominator of the confidence interval in the mean calculation above.

One possible work flow would be to apply the affine correction from the *GeostatsPy* package to formulate a the P5 and P95 porosity distributions for porosity in each well. Note, these distribution scenarios are based only on uncertainty in the mean porosity and assumes the distribution tails are not anchored (are free to shift).

```python
stdev1 = np.std(por1); stdev2 = np.std(por2)
por1r05 = GSLIB.affine(por1,tmean=ci_95_por1[0],tstdev=stdev1)
por1r95 = GSLIB.affine(por1,tmean=ci_95_por1[1],tstdev=stdev1)
por2r05 = GSLIB.affine(por2,tmean=ci_95_por2[0],tstdev=stdev2)
por2r95 = GSLIB.affine(por2,tmean=ci_95_por2[1],tstdev=stdev2)

plt.subplot(121)
plt.hist(por1r05, facecolor='yellow',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.8,density=True,cumulative=True,edgecolor='black',label='P05')
plt.hist(por1, facecolor='orange',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.8,density=True,cumulative=True,edgecolor='black',label='Expected')
plt.hist(por1r95, facecolor='red',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.8,density=True,cumulative=True,edgecolor='black',label='P95')

plt.xlim([0.0,0.4]); plt.ylim([0,1.0])
plt.xlabel('Porosity (fraction)'); plt.ylabel('Cumulative Probability'); plt.title('Porosity Distribution Uncertainty - Well 1')
plt.legend(loc='upper left')
plt.grid(True)

plt.subplot(122)
plt.hist(por2r05, facecolor='yellow',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.8,density=True,cumulative=True,edgecolor='black',label='P05')
plt.hist(por2, facecolor='orange',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.8,density=True,cumulative=True,edgecolor='black',label='Expected')
plt.hist(por2r95, facecolor='red',bins=np.linspace(0.0,0.4,50),histtype="stepfilled",alpha=0.8,density=True,cumulative=True,edgecolor='black',label='P95')

plt.xlim([0.0,0.4]); plt.ylim([0,1.0])
plt.xlabel('Porosity (fraction)'); plt.ylabel('Cumulative Probability'); plt.title('Porosity Distribution Uncertainty - Well 2')
plt.legend(loc='upper left')
plt.grid(True)

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.3)
plt.show()
```

We just calculated a scenario-based uncertainty model for the porosity distribution around wells 1 and 2. Of course, we could actually sample porosity means continuously from our confidence calculation as we have access to the complete distribution for uncertainty in the mean porosity of both wells.

Communicating uncertainty is powerful, but always remember to state the assumptions. For example, here we assumed:

1. The population distribution of porosity for each well is Gaussian distributed
2. That the samples are independent.

One can check the Excel file linked above with the confidence interval calculated by hand and confirm that this result is correct.

#### Are the Porosity Values from Unit 1 and Unit 2 Significantly Different?

Let's first visually compare the porosity from the two units.  We will use a box and wisker plot.

* Box and Whiskers plot is a built in function with DataFrames

```python
df.boxplot()
```

* We return the 'plt' label so that we can further improve the plot

```python
plt = df.boxplot(grid = False,fontsize = 10)
plt.set_title('Unit 1 and 2 Porosity'); plt.set_xlabel("Unit"); plt.set_ylabel('Porosity (fraction)')
```

So the porosity distributions seem quite different between the two units.

* Let's check and see if they are **statistically significantly different**

#### Hypothesis Testing of Difference Between Unit 1 and 2

The confidence intervals help with uncertainty in the distributions of porosity.  Now let's try to figure out if:

1. could we combine units 1 and 2?
2. did something change between the 2 units?

Now, let's try the t test, hypothesis test for difference in means.  This test assumes that the variances are similar along with the data being Gaussian distributed (see the course notes for more on this).  This is our test:

\begin{equation}
H_0: \mu_{X1} = \mu_{X2}
\end{equation}

\begin{equation}
H_1: \mu_{X1} \ne \mu_{X2}
\end{equation}

For the resulting t-statistic and p-value we run this command.

```python
t_pooled, p_pooled = st.ttest_ind(por1,por2)
print('The t statistic is ' + str(round(t_pooled,3)) + ' and the p-value is ' + str(round(p_pooled,3)))
```

    The t statistic is -2.981 and the p-value is 0.005

The p-value, $p$, is the symmetric interval probaiblity our outside.  In other words the $p$ reported is 2 x cumulative probaiblity of the t statistic applied to the sampling t distribution.  Another way to look at it, if one used the $\pm t_{t_{statistic},.d.f}$ statistic as thresholds, $p$ is the probability being outside this symmetric interval. So we will reject the null hypothesis if $p \lt \alpha$.  From the p-value alone it is clear that we would reject the null hypothesis and accept the alternative hypothesis that the means are not equal.

In case you want to compare the t-statistic to t-critical, we can apply the inverse of the student's t distribution at $\frac{\alpha}{2}$ and $1-\frac{\alpha}{2}$ to get the upper and lower critcal values.

```python
t_critical = st.t.ppf([0.025,0.975], df=len(por1)+len(por2)-2)
print('The t crical lower and upper values are ' + str(np.round(t_critical,2)))
```

    The t crical lower and upper values are [-2.02  2.02]

We can observe that, as expected, the t-statistic is outside the t-critcal interval.  These results are exactly what we got when we worked out the problem by hand in Excel, but so much more efficient!

**The porosity from the 2 units are significantly different.**

Now let's try the t-test, hypothesis test for difference in means allowing for unequal variances, this is also known as the Welch's t test.  All we have to do is set the parameter 'equal_var' to false, note it defaults to true (e.g. the command above).

```python
st.ttest_ind(por1, por2, equal_var = False)
```

    Ttest_indResult(statistic=-2.9808897468855644, pvalue=0.005502572350112333)

Once again we can see by $p$ that we will clearly reject the null hypothesis.

Let's now compare the variances with the F-test for difference in variances.

\begin{equation}
H_0: \sigma^{2}_{X2} \le \sigma^{2}_{X1}
\end{equation}

\begin{equation}
H_1: \sigma^{2}_{X2} \gt \sigma^{2}_{X1}
\end{equation}

Note, by ordering the variances we eliminate the case of $\sigma^{2}_{X2} \lt \sigma^{2}_{X1}$.

Details about the test are available in the course notes (along with assumptions such as Gaussian distributed) and this example is also worked out by hand in the linked Excel workbook.  We can accomplish the F-test in with SciPy.Stats the function with one line of code if we calculate the ratio of the sample variances ensuring that the larger variance is in the numerator and get the degrees of freedom using the len() command, ensuring that we are consistent with the numerator degrees of freedom set as 'dfn' and the denominator degrees of freedom set as 'dfd'.  We take a p-value of $1-p$ since the test is configured to be a single, right tailed test.

```python
p_value = 1 - st.f.cdf(np.var(por2)/np.var(por1), dfn=len(por2)-1, dfd=len(por1)-1)
print('The p-value for the F-test for difference is variances is ' + str(round(p_value,3)))
```

    The p-value for the F-test for difference is variances is 0.019

Once again we would clearly reject the null hypothesis since $p \lt alpha$ and assume that the variances are not equal.  Note this is a single tail test so our p-critical is 0.05 for a 95% confidence level.

#### Reporting the Hypothesis Test Results

The difference in the means and variances were tested for statistical significance for porosity measured in wells 1 and 2.  In all cases we rejected the null hypotheses and adopted the alternative hypotheses that the porosity distributions' means and variances for well 1 and 2 are statistically significant in their differences. It is possible that Well 1 and 2 have drilled into different rock units or have encountered nonstationary behavoirs in a single rock unit. This result may be applied to update subsurface maps and consider a difference in the subsurface at each well for development decision making.

#### Comments

We are just scratching the surface for the application of confidence intervals and hypothesis tests to subsurface modeling.  Once again, there are a lot of details left out of the problem formulation and assumptions, see the course notes for more coverage.  By running the same confidence interval and hypothesis tests: 1) by hand in Excel and with 2) R and now here in 3) Python code, I hope this demonstration will enable and encourage more engineers and scientists to make these R and Python tools part of their common practice. I'm always happy to discuss,

I hope this was helpful,

*Michael*

#### The Author:

### Michael Pyrcz, Associate Professor, University of Texas at Austin
*Novel Data Analytics, Geostatistics and Machine Learning Subsurface Solutions*

With over 17 years of experience in subsurface consulting, research and development, Michael has returned to academia driven by his passion for teaching and enthusiasm for enhancing engineers' and geoscientists' impact in subsurface resource development.

For more about Michael check out these links:

#### [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1)

#### Want to Work Together?

I hope this content is helpful to those that want to learn more about subsurface modeling, data analytics and machine learning. Students and working professionals are welcome to participate.

* Want to invite me to visit your company for training, mentoring, project review, workflow design and / or consulting? I'd be happy to drop by and work with you!

* Interested in partnering, supporting my graduate student research or my Subsurface Data Analytics and Machine Learning consortium (co-PIs including Profs. Foster, Torres-Verdin and van Oort)? My research combines data analytics, stochastic modeling and machine learning theory with practice to develop novel methods and workflows to add value. We are solving challenging subsurface problems!

* I can be reached at mpyrcz@austin.utexas.edu.

I'm always happy to discuss,

*Michael*

Michael Pyrcz, Ph.D., P.Eng. Associate Professor The Hildebrand Department of Petroleum and Geosystems Engineering, Bureau of Economic Geology, The Jackson School of Geosciences, The University of Texas at Austin


```python
%%html
<button onclick="run_all()">CLICK HERE TO BEGIN</button>

```

# Probability of Independent Events

## Introduction

Our lives are full of random events! To be an informed and successful person, it is important to get a "feel" for randomness. What is a random event? Flipping a coin, rolling dice, shuffling cards, and lottery draws are all examples of random events. There are **two basic types** of random events: **dependent** random events, and **independent** random events.

What are **dependent** random events? Sometimes the occurrence of an event can affect the probability of the next event occurring. For example, consider removing a playing card from a deck of cards. As you take each card, there are fewer cards left in the deck, and so the probability of drawing a particular card changes on later draws.

This notebook will focus on **independent** events. As you've probably guessed, these are events that are **not affected** by previous events.

**Note:** To express the likelihood of an event occurring, we assign a **probability** to it, usually expressed as a decimal or fraction that is between zero and one.

### Die Simulator

Click the "Roll Dice!" button as many times as you'd like. Do you think that the current number on the die affects the likelihood of the next number occurring?

```python
from ipywidgets import Output, VBox
from random import choice
import time
from IPython.display import Image, display, clear_output
from ipywidgets import widgets

Animation = Image(filename="Images/DiceAnimationInfinite.gif", width = 80, height = 80)
N1 = Image(filename="Images/Dice1.gif", width = 50, height = 50)
N2 = Image(filename="Images/Dice2.gif", width = 50, height = 50)
N3 = Image(filename="Images/Dice3.gif", width = 50, height = 50)
N4 = Image(filename="Images/Dice4.gif", width = 50, height = 50)
N5 = Image(filename="Images/Dice5.gif", width = 50, height = 50)
N6 = Image(filename="Images/Dice6.gif", width = 50, height = 50)

### Widgets ###
out1 = Output()
button1 = widgets.Button(description = "Roll Die!")

### Functions ###
def on_button1_clicked(b):
    subj = [N1, N2, N3, N4, N5, N6]
    with out1:
        clear_output()
        display(Animation)
        time.sleep(2.5)
        clear_output(wait=True)
        display(choice(subj))

### Display Widgets ###
display(VBox([button1, out1]))

### Initialize Button Click Action ###
button1.on_click(on_button1_clicked)
```

### Experimental Probability

One way of estimating the probability of an event is to do so experimentally.  If we do an experiment $x$ times and the event we want occurs $n$ times, then the **experimental probability** of the event is just:

\begin{equation}
\text{Probability of an event happening} = \dfrac{\text{Number of occurrences of event}}{\text{Number of experiments done}}=\dfrac{n}{x}
\end{equation}

#### Question

Consider the event of rolling a particular number (say 3) on the above six-sided die.  Roll the above die $x$ times (say 20 times) and count how many times ($n$) the particular number occurs.

*What is your experimental estimate for the probability of rolling a given number on a six-sided die?  Would you get the same estimate if you did it again?*

### Experimental Die Simulator

Experimental estimates of probability improve if the number of times the experiment is done ($x$) is large. Let's demonstrate this through an example. The slider below allows you to choose how many times the die is rolled. The chart below keeps track of the value of each roll. Start at the bottom of the slider and make your way to the top. Do you see any pattern forming?

```python
from ipywidgets import interactive
import matplotlib.pyplot as plt
import numpy as np
import random
```

```python
def f(x=50):
    min = 1
    max = 6
    D1, D2, D3, D4, D5, D6 = 0, 0, 0, 0, 0, 0
    for i in range(x):
        num1 = random.randint(min, max)
        if num1 == 1:
            D1 += 1
        elif num1 == 2:
            D2 += 1
        elif num1 == 3:
            D3 += 1
        elif num1 == 4:
            D4 += 1
        elif num1 == 5:
            D5 += 1
        elif num1 == 6:
            D6 += 1

    plt.figure()
    plt.bar([1,2,3,4,5,6],[D1,D2,D3,D4,D5,D6])
    plt.ylim(0, (x/6)*2)
    plt.ylabel('Total Number of Occurences ($n$)')
    plt.xlabel('Die Value')
    plt.show()

interactive_plot = interactive(f, x=widgets.IntSlider(min=50,max=5000,step=495, continuous_update=False))
output = interactive_plot.children[-1]
output.layout.height = '300px'
interactive_plot
```

### Review Questions

*What pattern emerges when the number of rolls increases? In other words, what happens to the graph when you move the slider to 5000 rolls?*

```python
def multiple_choice(O1, O2, O3, O4):
    question_prompts = [O1, O2, O3, O4]
    answer = question_prompts[0]
    letters = ["a)", "b)", "c)", "d)"]

    #Starts and ends bolded letters
    start = "\033[1m"
    end = "\033[0;0m"

    #Randomly shuffles the options
    random.shuffle(question_prompts)

    #Prints the letters a) - d) in sequence with randomly chosen options
    for i in range(4):
        selection = question_prompts.pop()
        print(start + letters[i] + end + selection)

        #Stores the correct answer
        if selection == answer:
            letter_answer = letters[i]

    button1 = widgets.Button(description="a)")
    button2 = widgets.Button(description="b)")
    button3 = widgets.Button(description="c)")
    button4 = widgets.Button(description="d)")

    button1.style.button_color = 'Whitesmoke'
    button2.style.button_color = 'Whitesmoke'
    button3.style.button_color = 'Whitesmoke'
    button4.style.button_color = 'Whitesmoke'

    container = widgets.HBox(children=[button1,button2,button3,button4])
    display(container)

    def on_button1_clicked(b):
        if "a)" == letter_answer:
            print("Correct! 👏", end='\r')
            button1.style.button_color = '#abffa8'; button2.style.button_color = 'Whitesmoke'
            button3.style.button_color = 'Whitesmoke'; button4.style.button_color = 'Whitesmoke'
        else:
            print("Try again! ", end='\r')
            button1.style.button_color = '#ffbbb8'; button2.style.button_color = 'Whitesmoke'
            button3.style.button_color = 'Whitesmoke'; button4.style.button_color = 'Whitesmoke'

    def on_button2_clicked(b):
        if "b)" == letter_answer:
            print("Correct! 👏", end='\r')
            button1.style.button_color = 'Whitesmoke'; button2.style.button_color = '#abffa8'
            button3.style.button_color = 'Whitesmoke'; button4.style.button_color = 'Whitesmoke'
        else:
            print("Try again! ", end='\r')
            button1.style.button_color = 'Whitesmoke'; button2.style.button_color = '#ffbbb8'
            button3.style.button_color = 'Whitesmoke'; button4.style.button_color = 'Whitesmoke'

    def on_button3_clicked(b):
        if "c)" == letter_answer:
            print("Correct! 👏", end='\r')
            button1.style.button_color = 'Whitesmoke'; button2.style.button_color = 'Whitesmoke'
            button3.style.button_color = '#abffa8'; button4.style.button_color = 'Whitesmoke'
        else:
            print("Try again! ", end='\r')
            button1.style.button_color = 'Whitesmoke'; button2.style.button_color = 'Whitesmoke'
            button3.style.button_color = '#ffbbb8'; button4.style.button_color = 'Whitesmoke'

    def on_button4_clicked(b):
        if "d)" == letter_answer:
            print("Correct! 👏", end='\r')
            button1.style.button_color = 'Whitesmoke'; button2.style.button_color = 'Whitesmoke'
            button3.style.button_color = 'Whitesmoke'; button4.style.button_color = '#abffa8'
        else:
            print("Try again! ", end='\r')
            button1.style.button_color = 'Whitesmoke'; button2.style.button_color = 'Whitesmoke'
            button3.style.button_color = 'Whitesmoke'; button4.style.button_color = '#ffbbb8'

    button1.on_click(on_button1_clicked)
    button2.on_click(on_button2_clicked)
    button3.on_click(on_button3_clicked)
    button4.on_click(on_button4_clicked)

Option1 = "The total for each dice value becomes roughly the same as the number of rolls increases."
Option2 = "The graph will look like a staircase; i.e. 1 has the lowest total and 6 has the highest."
Option3 = "The graph will look like a hill; i.e. the middle values (3 and 4) will have the highest totals."
Option4 = "No pattern emerges."

multiple_choice(Option1, Option2, Option3, Option4)
```

*Read the number of number of occurrences ($n$) of a particular die value (say 3) and calculate the experimental probability as the number of experiments ($x$) increases.  To what value does the experimental probability trend?*

```python
#import random

#Assign each question to these four variables. Note: there must be 4 questions and 1 answer.
Option1 = "Probability = 1/6" #Make this the answer. It'll be randomized later.
Option2 = "Probability = 1/2"
Option3 = "Probability = 1/3"
Option4 = "Probability = 1/4"

multiple_choice(Option1, Option2, Option3, Option4)
```

### Experimental Coin Simulator

Let's switch from dice to coins as a further example. A coin has two sides: "Head" and "Tails".  The following simulator counts the number of occurrences of each when a fair coin is flipped experimentally $x$ times.

**Question:** *Similar to the dice bar chart above, what shape/pattern emerges if you toss a coin thousands of times?*

```python
def g(x=10):
    min = 1
    max = 2
    Heads, Tails = 0,0
    for i in range(x):
        num1 = random.randint(min, max)
        if num1 == 1:
            Heads += 1
        elif num1 == 2:
            Tails += 1

    plt.figure()
    plt.bar(["Heads","Tails"],[Heads, Tails])
    plt.ylim(0, (x/2)*2)
    plt.ylabel('Total')
    plt.xlabel('Coin Value')
    plt.show()

interactive_plot = interactive(g, x=widgets.IntSlider(min=10,max=5000,step=499, continuous_update=False))
output = interactive_plot.children[-1]
output.layout.height = '300px'
interactive_plot
```

```python
Option1 = "The two columns become more and more similar in height."
Option2 = "No pattern!"
Option3 = "The 'Tails' column becomes twice as big as the 'Heads' column."
Option4 = "'Heads' is more likely and so its column becomes bigger."

multiple_choice(Option1, Option2, Option3, Option4)
```

**Question:** *Calculate the experimental probability of the event of getting a "Head" on a single coin toss using your data for a large number of experiments.  What do you think the exact probability is?*

### Independence and the Gambler's Fallacy

We have seen, based on experiment, that a fair coin has probability of $\frac{1}{2}$ of resulting in "Heads".

**Question:** *Suppose you toss a fair coin and it comes up "Heads" **nine times** in a row. What is the chance that **the next toss** will also be a "Head"?*

```python
Option1 = "1/2 chance (50%)"
Option2 = "Nearly zero percent chance!"
Option3 = "1/6 chance"
Option4 = "1/4 chance (25%)"

multiple_choice(Option1, Option2, Option3, Option4)
```

**Note:** It is common for people to think that a Tail is "overdue", but the next toss of the coin is **totally independent** of any previous tosses.

Saying "a Tail is due" is called **The Gambler's Fallacy**. Don't fall for it!

## Calculating the Probability of Independent Events Theoretically

We have seen how to estimate the probability of an event experimentally.  In many cases we can calculate an exact **theoretical probability** using the following formula:

\begin{equation}
\text{Probability of an event happening} = \dfrac{\text{Number of ways it can happen}}{\text{Total number of possible outcomes}}
\end{equation}

**Example:** What is the probability of getting a "Tail" when tossing a coin?

- **Number of ways it can happen: 1** (Tail)
- **Total number of possibly outcomes: 2** (Head and Tail)

Therefore, the probability of getting a "Tail" = $\dfrac{1}{2}=0.5$

**Example:** What is the probability of getting a "3" or "6" when rolling a die?

- **Number of ways it can happen: 2** ("3" and "6")
- **Total number of possibly outcomes: 6** ("1", "2", "3", "4", "5", "6")

Therefore, the probability = $\dfrac{2}{6}=\dfrac{1}{3}=0.333...$

**Notes:**
- Probabilities are often shown as a **decimal**, **fraction**, or a **percentage**. Here are **three** ways of showing the probability of flipping a "Head":

    - As a decimal: **0.5**
    - As a fraction: **1/2**
    - As a percentage: **50%**

- The **theoretical probability formula** looks similar to the **experimental formula**, but both formulas are quite different.  Using the theoretical formula, no experiment is done. We simply consider the possible outcomes and count those corresponding to the event of interest. Unlike an experimental probability, we will always get the **exact same answer** when we calculate the theoretical probability! *The theoretical probability is the true probability*.  Experimental probabilities *approximate* the true probability. The approximations improve as the number of experiments ($x$) is increased.

### Review Questions

*What is the probability of getting a "3" or "5" when rolling a die?*

```python
Option1 = "Probability = 1/3"
Option2 = "Probability = 1/6"
Option3 = "Probability = 1"
Option4 = "Probability = 3/6 or 5/6"

multiple_choice(Option1, Option2, Option3, Option4)
```

*What is the probability of getting a "1", "2", or "3" when rolling a die?*

```python
Option1 = "Probability = 3/6 or 1/2"
Option2 = "Probability = 1/6"
Option3 = "Probability = 1/3"
Option4 = "Probability = 0"

multiple_choice(Option1, Option2, Option3, Option4)
```

## The Probability of Multiple Independent Events

What if we wish to compute the probability of **multiple** independent events happening? For example, what is the probability of getting **three "Heads" in a row**?  Or, equivalently, if we **flip three coins**, what is the probability of getting exactly **three "Heads"**?

The following chart does the experiment of flipping three coins $x$ times and keeps track of the number of times ($n$) that three "Heads" occurs.

```python
repeated_heads = 3

def h(n):
    sum1 = 0
    for i in range(n):
        num = random.randint(0,1)
        sum1 += num

    if sum1 == n:
        return True
    else:
        return False

def a(x=1000):
    good = 0
    bad = 0
    for j in range(x):
        y = h(repeated_heads)
        if y == True:
            good += 1
        else:
            bad += 1

    plt.figure()
    plt.bar(["Total Tosses","3 Heads in a Row"],[bad+good, good])
    plt.ylim(0, x + 10000)
    plt.ylabel('Total number of occurrences ($n$)')
    plt.xlabel('')
    plt.show()
    #print("After flipping a coin three times for " + str(x) + " iterations, the probability of getting three heads in a row can be approximated as: (Number of 3 Heads in a row) / (Total number of trials) = ", str(good) + "/" + str(good+bad) + " =", round(good/(good+bad), 5))
    print("After flipping three coins " + str(x) + " times, the probability of getting three heads is experimentally:\n (Number of times 3 Heads occurs) / (Total number of trials) = n/x =", str(good) + "/" + str(good+bad) + " =", round(good/(good+bad), 5))

interactive_plot = interactive(a, x=widgets.IntSlider(min=1000,max=100000,step=9900, continuous_update=False))
output = interactive_plot.children[-1]
output.layout.height = '300px'
interactive_plot
```

### Review Question

*As you move the slider to the right, what theoretical probability do your experimental results approach?*

```python
Option1 = "0.125"
Option2 = "0.5"
Option3 = "1.5"
Option4 = "0.25"

multiple_choice(Option1, Option2, Option3, Option4)
```

### Computing Multiple Independent Event Probabilities Theoretically

**Question:** How do we quickly compute the theoretical probability of getting three Heads on a flip of three coins?

**Answer:** We can calculate the chances of two or more **independent** events by **multiplying** the chances. For each toss of a coin, a "Head" has a probability of 0.5. And so the chance of getting **three Heads in a row is**:

\begin{equation}
0.5 \times 0.5 \times 0.5 = \boldsymbol{0.125}
\end{equation}

Another way of seeing this is to write out all of the possibilities. We call this the *sample space* of the random trial. In this example, the random trial is flipping three coins. Below is this sample space:

Observe that only **one** of the **eight** possibilities is "HHH". Hence, the probability of getting three Heads on a flip of three coins is **1/8 = 0.125**.

**Notes:**
- If you multiply probabilities in this way you must express them in decimal form first, not as percents!  If you want to express the final result as a percent, you can convert it to a percent by multiplying by 100%. Here for example, 0.125 becomes 12.5%.
- Computing multiple independent events theoretically in this manner (through multiplication) requires that each outcome is **equally likely**.

### Review Questions

*What is the probability of 7 heads in a row? (Hint: use a calculator).*

```python
Option1 = "0.0078125"
Option2 = "0.03125"
Option3 = "0.015625"
Option4 = "0.5"

multiple_choice(Option1, Option2, Option3, Option4)
```

*Given that **we have just got 6 heads in a row**, what is the probability that **the next toss** is also a head?  Or equivalently, suppose we **flip 7 coins** and find that the **first six are heads**, what is the probability the last coin is a **head**? *

```python
Option1 = "0.5"
Option2 = "0.03125"
Option3 = "0.015625"
Option4 = "0.0078125"

multiple_choice(Option1, Option2, Option3, Option4)
```

### A Cleaner Notation

Typically, we use "P" to mean "Probability Of". For Independent Events, we have

\begin{equation}
    \text{P(A and B) = P(A)} \times \text{P(B)}
\end{equation}

So we denote "the probability of getting 2 heads in a row" by

\begin{equation}
    \text{P(Heads and Heads) = P(Heads)} \times \text{P(Heads) = 0.5} \times \text{0.5 = 0.25}.
\end{equation}

### Review Questions

*How would you denote: "the probability of rolling a "1" twice in a row and then rolling a "5" on a die"? Or equivalently when rolling three dice that the first two are "1" and the last one is a "5"?*

```python
Option1 = "P('1' and '1' and '5')"
Option2 = "P('1' and '5')"
Option3 = "P('1')P('5')"
Option4 = "P('1' and '1' or '5')"

multiple_choice(Option1, Option2, Option3, Option4)
```

*For a coin, let H = "Heads" and T = "Tails". What is P(H and T and T and H)? Input your exact decimal answer in the textbox bellow.*

```python
text = widgets.Text(
    value='',
    placeholder='Type probability (decimal)',
    description="Probability =",
    disabled=False
)

button = widgets.Button(description="Hint")
hint = [widgets.Label("P(H and T and T and H) = P(H) \u00D7 P(T) \u00D7 P(T) \u00D7 P(H). Recall that P(H) = P(T)=0.5")]
display(text)

def on_button_clicked(b):
    widgets.Box(hint)
    #print("P(H and T and T and H) = P(H) \u00D7 P(T) \u00D7 P(T) \u00D7 P(H). Recall that P(H) = P(T)=0.5")

def callback(wdgt):
    if (wdgt.value == "0.0625") or (wdgt.value == ".0625") or (wdgt.value == "1/16"):
        print("Correct! 👏                                                                                ", end='\r')
        text.on_submit(callback)
    else:
        print("Try again! Hint: P(H and T and T and H) = P(H) \u00D7 P(T) \u00D7 P(T) \u00D7 P(H). Recall that P(H) = P(T) = 0.5", end='\r')
        text.on_submit(callback)
        #display(button)
        #button.on_click(on_button_clicked)

text.on_submit(callback)
```

## Application Example

Suppose we have two groups:
- A member of each group gets randomly chosen for the winners' circle
- Then, one of those gets randomly chosen to get the grand prize.

**Question:** *What is your chance of winning the grand prize if:*
- *there is a **1/5 chance** of going to the winners' circle, and*
- *there is a **1/2** chance of winning the big prize?*

```python
text2 = widgets.Text(
    value='',
    placeholder='Type probability (decimal)',
    description="Probability =",
    disabled=False
)

display(text2)

def callback2(wdgt):
    if (wdgt.value == "0.1") or (wdgt.value == ".1") or (wdgt.value == "1/10"):
        print("Correct! 👏                                                                ", end='\r')
        text2.on_submit(callback2)
    else:
        print("Try again! Hint: You have a 1/5 chance followed by a 1/2 chance of winning.", end='\r')
        text2.on_submit(callback2)

text2.on_submit(callback2)

```

## Conclusion

- Experimental Probability = (Number of occurrences of event)/(Number of experiments done)
- Theoretical Probability = (Number of ways event can happen)/(Total number of possible outcomes)
- Dependent events (removing cards from a deck of cards) are affected by **previous events**
- Independent events (such as rolling a die) are **not** affected by previous events
- Calculate the probability of 2 or more **independent** events by **multiplying**

```python
%%html

<form action="javascript:code_toggle()"><input type="submit" id="toggleButton" value="Show Code"></form>
```

[](https://github.com/callysto/curriculum-notebooks/blob/master/LICENSE.md)


Read two saved template Models
Interpolate Ge and Ph data to create input and output targets for training.

1. Load existing template NN models
2. Create parameters for generating training data from source template models
3. Initialize template interpolation (ti) model
4. Train ti model
5. Generate test data for graphing
6. Save ti model to file
7. Generate blender data from ti model output

```python
import torch as torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

import os
import numpy as np
import json
import math as m
import random
import time
import NNArt as gy
from sympy.utilities.iterables import multiset_permutations

import plotly as py
import plotly.offline as offline
import plotly.graph_objs as go
import plotly.figure_factory as ff
import plotly.io as pio

CURRENT_MODEL_SAVE_DIR = './save/'

offline.init_notebook_mode(connected=True)
np.set_printoptions(precision=3, suppress=True)
```

```python
class Net(nn.Module):
    def __init__(self, layerSizes, name):
        super(Net, self).__init__()
        self.shape = layerSizes
        self.name = name
        self.seq = nn.Sequential(
            nn.Linear(layerSizes[0],layerSizes[1]),
            nn.ELU(),
            nn.Linear(layerSizes[1],layerSizes[2]),
            nn.ELU(),
            nn.Linear(layerSizes[2],layerSizes[3]),
            nn.ELU()
        )
    def forward(self, x):
        return self.seq(x)
```

```python
def phRcPhTrain(relModel,phModel, parms):
    epochs = parms['epochs']
    lrR = parms['lrR']
    lrP = parms['lrP']
    tModels = parms['tModels']
    device = parms['device']
    numSamples = parms['numSamples']
    rFilter = parms['rFilter']
    numContextDims=1 #extra emergent dimensions

    minLossP = 1000
    maxLossP = -10
    minLossR = 10
    maxLossR = 0
    criterion = torch.nn.MSELoss(reduction='sum')
    relOptimizer = torch.optim.Adam(relModel.parameters(), lr=lrR)
    phOptimizer = torch.optim.Adam(phModel.parameters(), lr=lrP)
    for epoch in range(epochs):
        #Each epoch get a new training set based on random input to the template models
        relTarget, phTarget, _ = gy.createPhRcTargetsFromModelList(tModels, numSamples, numContextDims)

        phOptimizer.zero_grad()
        relOptimizer.zero_grad()

        relOutput = relModel(phTarget)
        phOutput = phModel(relOutput)

        lossP = criterion(phOutput, phTarget)
        lossR = criterion(relOutput*rFilter, relTarget*rFilter)

        (lossP).backward(retain_graph=True)
        (lossR).backward()

        phOptimizer.step()
        relOptimizer.step()

        minLossP = min(lossP.data.item(), minLossP)
        maxLossP = max(lossP.data.item(), maxLossP)
        minLossR = min(lossR.data.item(), minLossR)
        maxLossR = max(lossR.data.item(), maxLossR)

        if (epoch) % (epochs/10) == 0:
            print("Epoch {0:,.0f} - lossR: {1:,.5f} - lossP: {2:,.5f}".format(
                epoch, lossR/numSamples, lossP/numSamples))

    print("Epoch {0:,.0f} - lossR: {1:,.5f}:{2:,.5f} - lossP: {3:,.5f}:{4:,.5f}".format(epoch+1,
                minLossR/numSamples, maxLossR/numSamples, minLossP/numSamples, maxLossP/numSamples))

    return
```

```python
'''
1. Load existing template NN models
templateModel2

'''

path=CURRENT_MODEL_SAVE_DIR+'templateModel'
tmModel0 = Net([2,10,15,21],'tmModel0')
tmModel1 = Net([2,10,15,21],'tmModel1')

tmModel0.load_state_dict(torch.load(path+'0.pth'))
tmModel1.load_state_dict(torch.load(path+'1.pth'))
tmModel0.eval()
tmModel1.eval()

tmModel0.shape, tmModel1.shape
```

    ([2, 10, 15, 21], [2, 10, 15, 21])

```python
'''
2. Initialize P-Gc-P

'''

#initialize network
manualSeed = 1

np.random.seed(manualSeed)
# random.seed(manualSeed)
torch.manual_seed(manualSeed)

relModel = Net([21,15,10,3],'relModel')
phModel = Net([3,10,15,21],'phModel')
relModel.train()
phModel.train()

# device = torch.device("cuda:0")
device = torch.device("cpu")
if device:
    relModel.to(device)
    phModel.to(device)
    tmModel0.to(device)
    tmModel1.to(device)
    print("Transfer to use ", device)

relModel.shape, phModel.shape
```

    Transfer to use  cpu

    ([21, 15, 10, 3], [3, 10, 15, 21])

```python
'''
3. Train template interpolation model

'''
start = time.time()

parms={ 'epochs':100000,'lrR':.0001,'lrP':.0002,'numSamples':1,'rFilter':torch.Tensor([1,1,.05]),
       'tModels':[tmModel0,tmModel1],'device':device}
phRcPhTrain(relModel,phModel,parms)

print('time: ', time.time()-start)
relModel.train = False
phModel.train = False
```

    Epoch 0 - lossR: 0.39050 - lossP: 0.67026
    Epoch 10,000 - lossR: 0.04482 - lossP: 0.07583
    Epoch 20,000 - lossR: 0.05111 - lossP: 0.01110
    Epoch 30,000 - lossR: 0.01354 - lossP: 0.00702
    Epoch 40,000 - lossR: 0.01232 - lossP: 0.00682
    Epoch 50,000 - lossR: 0.00028 - lossP: 0.00394
    Epoch 60,000 - lossR: 0.06151 - lossP: 0.00325
    Epoch 70,000 - lossR: 0.00077 - lossP: 0.00112
    Epoch 80,000 - lossR: 0.00238 - lossP: 0.00059
    Epoch 90,000 - lossR: 0.00154 - lossP: 0.00226
    Epoch 100,000 - lossR: 0.00000:3.46448 - lossP: 0.00002:6.15427
    time:  245.02311754226685

```python
'''
parms={ 'epochs':100000,'lrG':.0004,'lrP':.0005,'numSamples':1,'gFilter':torch.Tensor([1,1,.05]),
Epoch 100,000 - lossG: 0.00000:0.27628 - lossP: 0.00002:0.07341
parms={ 'epochs':100000,'lrG':.0002,'lrP':.0003,'numSamples':1,'gFilter':torch.Tensor([1,1,.05]),
Epoch 100,000 - lossG: 0.00000:0.23505 - lossP: 0.00001:0.06539
parms={ 'epochs':100000,'lrG':.0001,'lrP':.0002,'numSamples':1,'gFilter':torch.Tensor([1,1,.05]),
Epoch 100,000 - lossG: 0.00000:0.21284 - lossP: 0.00001:0.04594
Epoch 100,000 - lossG: 0.00000:0.21077 - lossP: 0.00001:0.03137
Epoch 100,000 - lossG: 0.00000:0.19727 - lossP: 0.00001:0.03196
'''
1==1
```

```python
'''
4. Review Emergent Context

'''
morphIndices=[0.,0.1,.2,.3,.5,.7,.9,1.0, 1.1]
timeIndices=[0.,0.1,.2,.3,.5,.7,.9,1.0, 1.1]

#sample source templates to get ph samples
relIn = gy.createRmInput(morphIndices, timeIndices, sPermutation=[])
phTout0 = tmModel0(torch.Tensor(relMapIn))
phTout1 = tmModel1(torch.Tensor(relMapIn))

#use template generated ph samples to feed through contex models
relCout0 = relModel(phTout0)
relCout1 = relModel(phTout1)

phCout0 = phModel(relCout0)
phCout1 = phModel(relCout1)

# gin.shape, pout2.shape, geCout2.shape, phCout2.shape
```

```python
g20 = np.array([relIn[0,:,1],relCout0[0,:,2].detach().numpy()])
g22 = np.array([relIn[2,:,1],relCout0[2,:,2].detach().numpy()])
g24 = np.array([relIn[4,:,1],relCout0[4,:,2].detach().numpy()])
g26 = np.array([relIn[6,:,1],relCout0[6,:,2].detach().numpy()])
g27 = np.array([relIn[7,:,1],relCout0[7,:,2].detach().numpy()])
g28 = np.array([relIn[8,:,1],relCout0[8,:,2].detach().numpy()])

g30 = np.array([relIn[0,:,1],relCout1[0,:,2].detach().numpy()])
g32 = np.array([relIn[2,:,1],relCout1[2,:,2].detach().numpy()])
g34 = np.array([relIn[4,:,1],relCout1[4,:,2].detach().numpy()])
g36 = np.array([relIn[6,:,1],relCout1[6,:,2].detach().numpy()])
g37 = np.array([relIn[7,:,1],relCout1[7,:,2].detach().numpy()])
g38 = np.array([relIn[8,:,1],relCout1[8,:,2].detach().numpy()])

gx = [g20,g30,g22,g32,g24,g34,g26,g36,g28,g38]

```

```python
layout = go.Layout(
    title = 'Initial Curves',
    height = 700,
    width = 800,
    hovermode= 'closest',
    xaxis= dict(
        title= 'X',
        range = [-.1,1.5],
    ),
    yaxis=dict(
        title= 'Y',
        range = [-.6,1.],
    )
)
layout.title = 'PhC changes into GeOutput Sp Space for GeInput: '
f = gy.graphCurves(gx, layout, labels=['s2t0', 's3t0','s2t2', 's3t2','s2t4', 's3t4','s2t6', 's3t6','s2t8', 's3t8', ], dashRange=gy.rangeList([range(2,4),range(6,8)]))

offline.iplot(f)

```

```python

i = 5
p0 = phTout0.data.numpy()[i]
p1 = phTout1.data.numpy()[i]

p01_0 = phCout0.data.numpy()[i]
p01_1 = phCout1.data.numpy()[i]

# nx = np.concatenate((p0,p01_0))
nx = np.concatenate((p1,p01_1))

gx = gy.FormatNetworkToGraphColumns(nx, xOffset=0, yOffset=14, colPoints=7)

layout = go.Layout(
    title = 'Initial Curves',
    height = 600,
    width = 600,
    hovermode= 'closest',
    xaxis= dict(
        title= 'X',
        range = [-.4,1.2],
    ),
    yaxis=dict(
        title= 'Y',
        range = [-.4,1.2],
    )
)
f = gy.graphCurves(gx, layout, dashRange=range(0,9))
offline.iplot(f)

```

```python

i = 7
p0 = phTout0.data.numpy()[i]
p1 = phTout1.data.numpy()[i]

p01_0 = phCout0.data.numpy()[i]
p01_1 = phCout1.data.numpy()[i]

# nx = np.concatenate((p0,p01_0))
nx = np.concatenate((p1,p01_1))

gx = gy.FormatNetworkToGraphColumns(nx, xOffset=0, yOffset=7, colPoints=7)

layout = go.Layout(
    title = 'Initial Curves',
    height = 600,
    width = 600,
    hovermode= 'closest',
    xaxis= dict(
        title= 'X',
        range = [-.3,1.1],
    ),
    yaxis=dict(
        title= 'Y',
        range = [-.2,.2],
    )
)
f = gy.graphCurves(gx, layout, dashRange=range(0,9))
offline.iplot(f)

```

```python

i = 7
p0 = phTout0.data.numpy()[i]
p1 = phTout1.data.numpy()[i]

p01_0 = phCout0.data.numpy()[i]
p01_1 = phCout1.data.numpy()[i]

# nx = np.concatenate((p0,p01_0))
nx = np.concatenate((p1,p01_1))

# gx = gy.FormatNetworkToGraphColumns(nx, xOffset=0, yOffset=7, colPoints=7)
# gx = gy.FormatNetworkToGraphColumns(nx, xOffset=0, yOffset=14, colPoints=7)
gx = gy.FormatNetworkToGraphColumns(nx, xOffset=7, yOffset=14, colPoints=7)

layout = go.Layout(
    title = 'Initial Curves',
    height = 600,
    width = 600,
    hovermode= 'closest',
    xaxis= dict(
        title= 'X',
        range = [-.5,.5],
    ),
    yaxis=dict(
        title= 'Y',
        range = [-.8,.2],
    )
)
f = gy.graphCurves(gx, layout, dashRange=range(0,9))
offline.iplot(f)

```

```python
'''
5. Graph data
'''
ti = 7
nx = np.array(phTout1[ti].data.numpy())
gx = gy.FormatNetworkToGraphColumns(nx, xOffset=0, yOffset=7, colPoints=7)

layout = go.Layout(
    title = 'Initial Curves',
    height = 600,
    width = 600,
    hovermode= 'closest',
    xaxis= dict(
        title= 'X',
        range = [-.4,1.1],
    ),
    yaxis=dict(
        title= 'Y',
        range = [-.25,.25],
    )
)

layout.title = 'Time 0 for different species combinations'
f = gy.graphCurves(gx, layout)
offline.iplot(f)
```

```python
'''
7. Save two template interpolation model to file

'''

# PATH = CURRENT_MODEL_SAVE_DIR +'PH PhRcPh-T0T1-mask 11p05.pth'
# torch.save(phModel.state_dict(), PATH)
# PATH = CURRENT_MODEL_SAVE_DIR +'REL PhRcPh-T0T1-mask 11p05.pth'
# torch.save(geModel.state_dict(), PATH)

```

    '\n7. Save two template interpolation model to file\n\n'

```python
'''
9. Generate blender data from template interpolation model output

#phOut.shape torch.Size([10, 7, 23])

'''

# phOut = phModel(torch.Tensor(torch.Tensor(inpt0)))

# x = phOut.detach().numpy()
# fb = gy.FormatNetworkToBlender(x, xOffset=0, yOffset=7, zOffset=14, colPoints=7)

# with open(CURRENT_MODEL_SAVE_DIR +'btest.json', 'w') as fp:
#     json.dump(fb.tolist(),fp)

# x.shape, fb.shape
```

    '\n9. Generate blender data from template interpolation model output\n\n#phOut.shape torch.Size([10, 7, 23])\n\n'

Model data generated from combinations of template models PGcP-T2T3-mask 11p05.ipynb


# 행렬의 연산과 성질

행렬에는 곱셈, 전치 이외에도 지수 함수 등의 다양한 연산을 정의할 수 있다. 각각의 정의와 성질을 알아보자.

##  행렬의 부호

행렬은 복수의 실수 값을 가지고 있으므로 행렬 전체의 부호는 정의할 수 없다. 하지만 행렬에서도 실수의 부호 정의와 유사한 기능을 가지는 정의가 존재한다. 바로 행렬의 양-한정(positive definite) 특성이다. (정방행렬에 한정됨)

쿼드라틱 Form의  결과는 실수값

모든 실수 공간 $\mathbb{R}^n$ 의 0벡터가 아닌 벡터 $x \in \mathbb{R}^n$ 에 대해 다음 부등식이 성립하면 행렬 $A$ 가 양-한정(positive definite)이라고 한다.

$$ x^T A x > 0 $$

만약 이 식이 등호를 포함한다면 양-반한정(positive semi-definite)이라고 한다.

$$ x^T A x \geq 0 $$

예를 들어 단위 행렬은 양-한정이다.

$$ x^TI x = x^T
\begin{bmatrix}
1&0&\cdots&0\\
0&1&\cdots&0\\
\vdots&\vdots&\ddots&\vdots\\
0&0&\cdots&1\\
\end{bmatrix}
x
= x_1^2 + x_2^2 + \cdots + x_n^2 > 0
$$

다음과 같은 행렬도 양-한정이다.

$$ M = \begin{bmatrix} 2&-1&0\\-1&2&-1\\0&-1&2 \end{bmatrix} $$

$$
\begin{align}
x^{\mathrm{T}}M x
&=  \begin{bmatrix} (2x_1-b)&(-x_1+2x_2-x_3)&(-x_2+2c) \end{bmatrix} \begin{bmatrix} x_1\\x_2\\x_3 \end{bmatrix} \\
&= 2{x_1}^2 - 2x_1x_2 + 2{x_2}^2 - 2x_2x_3 + 2{x_3}^2 \\
&= {x_1}^2+(x_1 - x_2)^{2} + (x_2 - x_3)^{2}+{x_3}^2
\end{align}
$$

## 행렬의 크기

행렬에는 크기 개념과 유사하게 하나의 행렬에 대해 하나의 실수를 대응시키는 norm, 대각 성분(trace), 행렬식(determinant)에 대한 정의가 존재한다.

### 행렬 Norm

행렬의 norm 정의는 다양하지만 그 중 많이 쓰이는 induced p-norm 정의는 다음과 같다.  시그마2개 : 가로 / 세로로 행렬화 해서 다 더해라. 1/p로 차원을 축소

$$ \Vert A \Vert_p = \left( \sum_{i=1}^m \sum_{j=1}^n |a_{ij}|^p \right)^{1/p} $$

이 중 $p=2$는 특별히 Frobenius norm 이라고 불리며 다음과 같이 표시한다.

$$ \Vert A \Vert_F = \sqrt{\sum_{i=1}^m \sum_{j=1}^n a_{ij}^2} $$

NumPy에서는 linalg 서브패키지의 `norm` 명령으로 Frobenious norm을 계산할 수 있다.

```python
A = (np.arange(9) - 4).reshape((3, 3))
A
```

    array([[-4, -3, -2],
           [-1,  0,  1],
           [ 2,  3,  4]])

```python
np.linalg.norm(A)
```

    7.745966692414834

### 대각 성분

대각 성분(trace) 행렬의 특성을 결정하는 숫자 중 하나로 정방 행렬(square matrix)에 대해서만 정의되며 다음과 같이 대각 성분(diaginal)의 합으로 계산된다.

$$ \operatorname{tr}(A) = a_{11} + a_{22} + \dots + a_{nn}=\sum_{i=1}^{n} a_{ii} $$

대각 성분은 다음과 같은 성질을 지닌다.

$$ \text{tr} (cA) = c\text{tr} (A) $$

$$ \text{tr} (A^T) = \text{tr} (A) $$

$$ \text{tr} (A + B) = \text{tr} (A) + \text{tr} (B)$$

$$ \text{tr} (AB) = \text{tr} (BA) $$

$$ \text{tr} (ABC) = \text{tr} (BCA) = \text{tr} (CAB) $$

실수는 그 자체가 trace임

특히 마지막 성질은 trace trick이라고 하여 이차 형식(quadratic form)의 값을 구하는데 유용하게 사용된다.

$$ x^TAx = \text{tr}(x^TAx) = \text{tr}(Axx^T)  = \text{tr}(xx^TA) $$

NumPy에서는 `trace` 명령으로 trace를 계산할 수 있다.

```python
np.trace(np.eye(3))
```

    3.0

### 행렬식

정방 행렬 $A$의 행렬식(determinant) $\det (A)$ 는 Laplace formula라고 불리는 재귀적인 방법으로 정의된다.

이 식에서 $a_{i,j}$는 $A$의 i행, j열 원소이고 $M_{i,j}$은 정방 행렬 $A$ 에서 i행과 j열을 지워서 얻어진 행렬의 행렬식이다.

$$ \det(A) = \sum_{j=1}^n (-1)^{i+j} a_{i,j} M_{i,j} $$

행렬식은 다음과 같은 성질을 만족한다.

$$ \det(I) = 1 $$

$$ \det(A^{\rm T}) = \det(A) $$

$$ \det(A^{-1}) = \frac{1}{\det(A)}=\det(A)^{-1} $$

$$ \det(AB) = \det(A)\det(B) $$

$$ A \in \mathbf{R}^n \;\;\; \rightarrow \;\;\; \det(cA) = c^n\det(A) $$

또한 역행렬은 행렬식과 다음과 같은 관계를 가진다.

$$ A^{-1} = \dfrac{1}{\det A} M = \dfrac{1}{\det A}
\begin{bmatrix}
M_{1,1}&\cdots&M_{1,n}\\
\vdots&\ddots&\vdots\\
M_{n,1}&\cdots&M_{n,n}\\
\end{bmatrix}
$$

NumPy에서는 linalg 서브패키지의 `det` 명령으로 det를 계산할 수 있다.

```python
A = np.array([[1, 2], [3, 4]])
A
```

    array([[1, 2],
           [3, 4]])

```python
np.linalg.det(A)
```

    -2.0000000000000004

## 전치 행렬과 대칭 행렬

전치 연산을 통해서 얻어진 행렬을 전치 행렬(transpose matrix)이라고 한다.

$$ [\mathbf{A}^\mathrm{T}]_{ij} = [\mathbf{A}]_{ji} $$

만약 전치 행렬과 원래의 행렬이 같으면 대칭 행렬(symmetric matrix)이라고 한다.

$$ A^\mathrm{T} = A $$

전치 연산은 다음과 같은 성질을 만족한다.

$$ ( A^\mathrm{T} ) ^\mathrm{T} = A  $$

$$ (A+B) ^\mathrm{T} = A^\mathrm{T} + B^\mathrm{T} $$

$$ \left( A B \right) ^\mathrm{T} = B^\mathrm{T} A^\mathrm{T}  $$

$$ \det(A^\mathrm{T}) = \det(A) $$

$$ (A^\mathrm{T})^{-1} = (A^{-1})^\mathrm{T} $$

## 지수 행렬

행렬 $A$에 대해 다음과 같은 급수로 만들어지는 행렬 $e^A=\exp A$ 를 지수 행렬(exponential matrix)이라고 한다.

A가 행렬인 경우임

테일러 시리즈.

$$ e^X = \sum_{k=0}^\infty \dfrac{X^k}{k!} = I + X + \dfrac{1}{2}X^2 + \dfrac{1}{3!}X^3 + \cdots $$

지수 행렬은 다음과 같은 성질을 만족한다.

$$ e^0 = I $$

$$ e^{aX} e^{bX} =  e^{(a+b)X} $$

$$ e^X e^{-X} = I $$

$$ XY = YX  \;\; \rightarrow \;\; e^Xe^Y = e^Ye^X = e^{X+Y} $$

## 로그 행렬

행렬 $A$에 대해 다음과 같은 급수로 만들어지는 행렬 $B=e^A$ 가 존재할 때, $A$를 $B$에 대한 로그 행렬이라고 하고 다음과 같이 표기한다.

$$ A = \log B $$

로그 행렬은 다음과 같은 성질은 만족한다.

만약 행렬 $A$, $B$가 모두 양-한정(positive definite)이고 $AB=BA$이면

$$ AB = e^{\ln(A)+\ln(B)} $$

만약 행렬 $A$의 역행렬이 존재하면

$$ A^{-1} = e^{-\ln(A)} $$

지수 행렬이나 로그 행렬은 NumPy에서 계산할 수 없다. SciPy의 linalg 서브패키지의 `expm`, `logm` 명령을 사용한다.

```python
A = np.array([[1.0, 3.0], [1.0, 4.0]])
A
```

    array([[ 1.,  3.],
           [ 1.,  4.]])

```python
B = sp.linalg.logm(A)
B
```

    array([[-1.02571087,  2.05142174],
           [ 0.68380725,  1.02571087]])

```python
sp.linalg.expm(B)
```

    array([[ 1.,  3.],
           [ 1.,  4.]])

06. 기초 선형대수/05. 행렬의 연산과 성질.ipynb

06. 기초 선형대수/05. 행렬의 연산과 성질.ipynb

06. 기초 선형대수/05. 행렬의 연산과 성질.ipynb


Computational Seismology

Discontinuous Galerkin Method - 1D Elastic Wave Equation, Homogeneous case

---

This notebook is part of the supplementary material
to [Computational Seismology: A Practical Introduction](https://global.oup.com/academic/product/computational-seismology-9780198717416?cc=de&lang=en&#),
Oxford University Press, 2016.

##### Authors:
* Stephanie Wollherr ([@swollherr](https://github.com/swollherr))
* David Vargas ([@dvargas](https://github.com/davofis))
* Heiner Igel ([@heinerigel](https://github.com/heinerigel))

## Basic Equations

The source-free elastic wave equation in 1D reads

\begin{align}
\partial_t \sigma - \mu \partial_x v & = 0 \\
\partial_t v - \frac{1}{\rho} \partial_x \sigma & = 0
\end{align}

with $\rho$ the density and $\mu$ the shear modulus. This equation in matrix-vector notation follows

\begin{equation}
\partial_t \mathbf{Q} + \mathbf{A} \partial_x \mathbf{Q} = 0
\end{equation}

where $\mathbf{Q} = (\sigma, v)$ is the vector of unknowns and the matrix $\mathbf{A}$ contains the parameters $\rho$ and $\mu$. We seek to solve the linear advection equation as a hyperbolic equation $ \partial_t u + \mu \ \partial_x u=0$. A series of steps need to be done:

1) The weak form of the equation is derived by multiplying both sides by an arbitrary test function.

2) Apply the stress Free Boundary Condition after integration by parts

3) We approximate the unknown field $\mathbf{Q}(x,t)$ by a sum over space-dependent basis functions $\ell_i$ weighted by time-dependent coefficients $\mathbf{Q}(x_i,t)$, as we did in the spectral elements method. As interpolating functions we choose the Lagrange polynomials and use $\xi$ as the space variable representing the elemental domain:

\begin{equation}
\mathbf{Q}(\xi,t) \ = \ \sum_{i=1}^{N_p} \mathbf{Q}(\xi_i,t) \ell_i(\xi) \qquad with \qquad \ell_i^{(N)} (\xi) \ := \ \prod_{j = 1, \ j \neq i}^{N+1} \frac{\xi - \xi_j}{\xi_i-\xi_j}, \quad i,j = 1, 2, \dotsc , N + 1
\end{equation}

4) The continuous weak form is written as a system of linear equations by considering the approximated displacement field. Finally, the semi-discrete scheme can be written in matrix-vector form as

\begin{equation}
\mathbf{M}\partial_t \mathbf{Q} = \mathbf{A}\mathbf{K}\mathbf{Q} - \mathbf{Flux}
\end{equation}

5) Time extrapolation is done after applying a standard 1st order finite-difference approximation to the time derivative, we call it the Euler scheme.

\begin{equation}
\mathbf{Q}^{t+1} \approx \mathbf{Q}^{t} + dt\mathbf{M}^{-1}(\mathbf{A}\mathbf{K}\mathbf{Q} - \mathbf{Flux})
\end{equation}

This notebook implements both Euler and Runge-Kutta schemes for solving the free source version of the elastic wave equation in a homogeneous media. To keep the problem simple, we use as spatial initial condition a Gauss function with half-width $\sigma$

\begin{equation}
Q(x,t=0)  = e^{-1/\sigma^2 (x - x_{o})^2}
\end{equation}

```
# Import all necessary libraries, this is a configuration step for the exercise.
# Please run it before the simulation code!
import numpy as np
import matplotlib
# Show Plot in The Notebook
matplotlib.use("nbagg")
import matplotlib.pyplot as plt

from gll import gll
from lagrange1st import lagrange1st
from flux_homo import flux
```

### 1. Initialization of setup

```
# Initialization of setup
# --------------------------------------------------------------------------
c      = 2500          # acoustic velocity [m/s]
tmax   = 2.0           # Length of seismogram [s]
xmax   = 10000         # Length of domain [m]
vs     = 2500          # Advection velocity
rho    = 2500          # Density [kg/m^3]
mu     = rho*vs**2     # shear modulus
N      = 4             # Order of Lagrange polynomials
ne     = 200           # Number of elements
sig    = 200           # Gaussian width
x0     = 5000          # x location of Gaussian
eps    = 0.4           # Courant criterion
iplot  = 20            # Plotting frequency
imethod = 'RK'      # 'Euler', 'RK'
#--------------------------------------------------------------------

# GLL points and integration weights
[xi,w] = gll(N)     # xi, N+1 coordinates [-1 1] of GLL points
                    # w Integration weights at GLL locations
# Space domain
le = xmax/ne        # Length of elements
ng = ne*N + 1

# Vector with GLL points
k=0
xg = np.zeros((N+1)*ne)
for i in range(0, ne):
    for j in range(0, N+1):
        k += 1
        xg[k-1] = i*le + .5*(xi[j]+1)*le

x = np.reshape(xg, (N+1, ne), order='F').T

# Calculation of time step acoording to Courant criterion
dxmin = np.min(np.diff(xg[1:N+1]))
dt = eps*dxmin/vs    # Global time step
nt = int(np.floor(tmax/dt))

# Mapping - Jacobian
J  = le/2 # Jacobian
Ji = 1/J  # Inverse Jacobian

# 1st derivative of Lagrange polynomials
l1d = lagrange1st(N)
```

### 2. Elemental Mass and Stiffness matrices

The mass and the stiffness matrix are calculated prior time extrapolation, so they are pre-calculated and stored at the beginning of the code.

The integrals defined in the mass and stiffness matrices are computed using a numerical quadrature, in this cases the GLL quadrature that uses the GLL points and their corresponding weights to approximate the integrals. Hence,

\begin{equation}
M_{ij}^k=\int_{-1}^1 \ell_i^k(\xi)  \ell_j^k(\xi) \ J \ d\xi = \sum_{m=1}^{N_p} w_m \ \ell_i^k (x_m)  \ell_j^k(x_m)\ J =\sum_{m=1}^{N_p} w_m \delta_{im}\ \delta_{jm} \ J= \begin{cases} w_i \ J \ \ \text{ if } i=j \\ 0 \ \ \ \ \ \ \ \text{   if } i \neq j\end{cases}
\end{equation}

that is a **diagonal mass matrix!**. Subsequently, the stiffness matrices is given as

\begin{equation}
K_{i,j}= \int_{-1}^1 \ell_i^k(\xi) \cdot \partial _x  \ell_j^k(\xi) \ d\xi= \sum_{m=1}^{N_p} w_m \ \ell_i^k(x_m)\cdot \partial_x \ell_j^k(x_m)= \sum_{m=1}^{N_p} w_m \delta_{im}\cdot \partial_x\ell_j^k(x_m)= w_i \cdot \partial_x \ell_j^k(x_i)
\end{equation}

The Lagrange polynomials and their properties have been already used, they determine the integration weights $w_i$ that are returned by the python method "gll". Additionally, the fist derivatives of such basis, $\partial_x \ell_j^k(x_i)$, are needed, the python method "Lagrange1st" returns them.

#### Exercise 1
Now we have all the ingredients to calculate the mass and stiffness matrix, initialize these matrices in the following cell. Compute the inverse mass matrix, keep in mind that it is diagonal.

```
#################################################################
# IMPLEMENT THE ELEMENTAL MASS MATRIX HERE!
#################################################################

#################################################################
# COMPUTE THE INVERSE MASS MATRIX HERE!
#################################################################

#################################################################
# IMPLEMENT THE ELEMENTAL STIFFNESS MATRIX HERE!
#################################################################
```

```

```

### 3. Flux Matrices

As in the case of finite volumes when we solve the 1D elastic wave equation for an homogeneous media, we assume the coefficients of matrix A to be constant inside the element.

\begin{equation}
\mathbf{A}=
     \begin{pmatrix}
       0 & -\mu \\
       -1/\rho & 0
     \end{pmatrix}
\end{equation}

Now we need to diagonalize $\mathbf{A}$. Introducing the seismic impedance $Z = \rho c$ with $c = \sqrt{\mu/\rho}$, we have

\begin{equation}
\mathbf{A} = \mathbf{R}^{-1}\mathbf{\Lambda}\mathbf{R}
\qquad\text{,}\qquad
\mathbf{\Lambda}=
     \begin{pmatrix}
       -c & 0 \\
        0 & c
     \end{pmatrix}
\qquad\text{,}\qquad
\mathbf{R} =
     \begin{pmatrix}
       Z & -Z \\
       1 &  1
     \end{pmatrix}
\qquad\text{and}\qquad
\mathbf{R}^{-1} = \frac{1}{2Z}
     \begin{pmatrix}
       1 & Z \\
      -1 & Z
     \end{pmatrix}
\end{equation}

We decompose the solution into right propagating $\mathbf{\Lambda}^{+}$ and left propagating eigenvalues $\mathbf{\Lambda}^{-}$ where

\begin{equation}
\mathbf{\Lambda}^{+}=
     \begin{pmatrix}
       -c & 0 \\
        0 & 0
     \end{pmatrix}
\qquad\text{,}\qquad
\mathbf{\Lambda}^{-}=
     \begin{pmatrix}
        0 & 0 \\
        0 & c
     \end{pmatrix}
\qquad\text{and}\qquad
\mathbf{A}^{\pm} = \mathbf{R}^{-1}\mathbf{\Lambda}^{\pm}\mathbf{R}
\end{equation}

This strategy allows us to formulate the Flux term in the discontinuous Galerkin method.

#### Exercise 2
Initialize all relevant matrices, i.e $R$, $R^{-1}$, $\mathbf{\Lambda}^{+}$, $\mathbf{\Lambda}^{-}$, $\mathbf{A}^{+}$, $\mathbf{A}^{-}$, $\mathbf{A}$.

```
#################################################################
# INITIALIZE ALL MATRICES HERE!
#################################################################
# Z =
# R =
# Rinv =

# Lm =
# Lp =

# Ap =
# Am =

# A =
```

```

```

### 4. Discontinuous Galerkin Solution

The principal characteristic of the discontinuous Galerkin Method is the communication between the element neighbors using a flux term, in general it is given

\begin{equation}
\mathbf{Flux} = \int_{\partial D_k} \mathbf{A}\mathbf{Q}\ell_j(\xi)\mathbf{n}d\xi
\end{equation}

this term leads to four flux contributions for left and right sides of the elements

\begin{equation}
\mathbf{Flux} = -\mathbf{A}_{k}^{-}\mathbf{Q}_{l}^{k}\mathbf{F}^{l} + \mathbf{A}_{k}^{+}\mathbf{Q}_{r}^{k}\mathbf{F}^{r} - \mathbf{A}_{k}^{+}\mathbf{Q}_{r}^{k-1}\mathbf{F}^{l} + \mathbf{A}_{k}^{-}\mathbf{Q}_{l}^{k+1}\mathbf{F}^{r}
\end{equation}

Last but not least, we have to solve our semi-discrete scheme that we derived above using an appropriate time extrapolation, in the code below we implemented two different time extrapolation schemes:

1) **Euler scheme**

\begin{equation}
\mathbf{Q}^{t+1} \approx \mathbf{Q}^{t} + dt\mathbf{M}^{-1}(\mathbf{A}\mathbf{K}\mathbf{Q} - \mathbf{Flux})
\end{equation}

2) Second-order **Runge-Kutta method** (also called predictor-corrector scheme)

\begin{eqnarray*}
k_1 &=& f(t_i, y_i) \\
k_2 &=& f(t_i + dt, y_i + dt k_1) \\
& & \\
y_{i+1} &=& y_i + \frac{dt}{2} (k_1 + k_2)
\end{eqnarray*}

with $f$ that corresponds with $\mathbf{M}^{-1}(\mathbf{A}\mathbf{K}\mathbf{Q} - \mathbf{Flux})$

#### Exercise 3
In order to validate our numerical solution, we ask to compare with the corresponding analytical solution as we did in the finite volumes implementation. Implement the analytical solution for the 1D elastic wave equation in homogeneous media and plot it together with the discontinuous Galerkin numerical solution.

```
# DG Solution, Time extrapolation
# ---------------------------------------------------------------

# Initalize solution vectors
Q    = np.zeros([ne, N+1, 2])
Qnew = np.zeros([ne, N+1, 2])

k1 = np.zeros([ne, N+1, 2])
k2 = np.zeros([ne, N+1, 2])

Q[:,:,0] = np.exp(-1/sig**2*((x-x0))**2)
Qs = np.zeros(xg.size)  # for plotting
Qv = np.zeros(xg.size)  # for plotting
Qa = np.zeros((2, xg.size))  # for analytical solution

# Initialize animated plot
# ---------------------------------------------------------------
fig = plt.figure(figsize=(10,6))
ax1 = fig.add_subplot(2,1,1)
ax2 = fig.add_subplot(2,1,2)
line1 = ax1.plot(xg, Qs, 'k', xg, Qa[0,:], 'r--', lw=1.5)
line2 = ax2.plot(xg, Qv, 'k', xg, Qa[1,:], 'r--', lw=1.5)
ax1.set_ylabel('Stress')
ax2.set_ylabel('Velocity')
ax2.set_xlabel(' x ')
plt.suptitle('Homogeneous Disc. Galerkin  - %s method'%imethod, size=16)

plt.ion()   # set interective mode
plt.show()

# ---------------------------------------------------------------
# Time extrapolation
# ---------------------------------------------------------------
for it in range(nt):
    if imethod == 'Euler': # Euler
        # Calculate Fluxes
        Flux = flux(Q, N, ne, Ap, Am)
        # Extrapolate each element using flux F
        for i in range(1,ne-1):
            Qnew[i,:,0] = dt * Minv @ (-mu * K @ Q[i,:,1].T - Flux[i,:,0].T) + Q[i,:,0].T
            Qnew[i,:,1] = dt * Minv @ (-1/rho * K @ Q[i,:,0].T - Flux[i,:,1].T) + Q[i,:,1].T

    elif imethod == 'RK':
        # Calculate Fluxes
        Flux = flux(Q, N, ne, Ap, Am)
        # Extrapolate each element using flux F
        for i in range(1,ne-1):
            k1[i,:,0] = Minv @ (-mu * K @ Q[i,:,1].T - Flux[i,:,0].T)
            k1[i,:,1] = Minv @ (-1/rho * K @ Q[i,:,0].T - Flux[i,:,1].T)

        for i in range(1,ne-1):
            Qnew[i,:,0] = dt * Minv @ (-mu * K @ Q[i,:,1].T - Flux[i,:,0].T) + Q[i,:,0].T
            Qnew[i,:,1] = dt * Minv @ (-1/rho * K @ Q[i,:,0].T - Flux[i,:,1].T) + Q[i,:,1].T

        Flux = flux(Qnew,N,ne,Ap,Am)

        for i in range(1,ne-1):
            k2[i,:,0] = Minv @ (-mu * K @ Qnew[i,:,1].T - Flux[i,:,0].T)
            k2[i,:,1] = Minv @ (-1/rho * K @ Qnew[i,:,0].T - Flux[i,:,1].T)

        # Extrapolate
        Qnew = Q + (dt/2) * (k1 + k2)
    else:
        raise NotImplementedError

    Q, Qnew = Qnew, Q

    # --------------------------------------
    # Animation plot. Display solution
    if not it % iplot:
        for l in line1:
            l.remove()
            del l
        for l in line2:
            l.remove()
            del l

        # stretch for plotting
        k = 0
        for i in range(ne):
            for j in range(N+1):
                Qs[k] = Q[i,j,0]
                Qv[k] = Q[i,j,1]
                k = k + 1

        #################################################################
        # IMPLEMENT THE ANALYTICAL SOLUTION HERE!
        #################################################################

        # --------------------------------------
        # Display lines
        line1 = ax1.plot(xg, Qs, 'k', xg, Qa[0,:], 'r--', lw=1.5)
        line2 = ax2.plot(xg, Qv, 'k', xg, Qa[1,:], 'r--', lw=1.5)
        plt.legend(iter(line2), ('D. Galerkin', 'Analytic'))
        plt.gcf().canvas.draw()
```

```

```

notebooks/Computational Seismology/The Discontinuous Galerkin Method/dg_elastic_homo_1d.ipynb


Table of Contents

0.0.1  Spiking Inputs

0.1  Exercise 1: Compute $dV_m$

1  Interactive Demo: Linear-IF neuron

1.1  Exercise 2: Compute $dV_m$ with inhibitory signals

2  Interactive Demo: LIF + inhibition neuron

2.1  Why do neurons spike?

2.2  The LIF Model Neuron

#  Neuromatch Academy: Week 1, Day 1, Tutorial 2
# Model Types: "How" models
__Content creators:__ Matt Laporte, Byron Galbraith, Konrad Kording

__Content reviewers:__ Dalin Guo, Aishwarya Balwani, Madineh Sarvestani, Maryam Vaziri-Pashkam, Michael Waskom

___
# Tutorial Objectives
This is tutorial 2 of a 3-part series on different flavors of models used to understand neural data. In this tutorial we will explore models that can potentially explain *how* the spiking data we have observed is produced

To understand the mechanisms that give rise to the neural data we save in Tutorial 1,  we will build simple neuronal models and compare their spiking response to real data. We will:
- Write code to simulate a  simple "leaky integrate-and-fire" neuron model
- Make the model more complicated — but also more realistic — by adding more physiologically-inspired details

```python
#@title Video 1: "How" models
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='PpnagITsb3E', width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=PpnagITsb3E

# Setup

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
```

```python
#@title Figure Settings
import ipywidgets as widgets #interactive display

%matplotlib inline
%config InlineBackend.figure_format = 'retina'
plt.style.use("https://raw.githubusercontent.com/NeuromatchAcademy/course-content/master/nma.mplstyle")

```

```python
#@title Helper Functions
def histogram(counts, bins, vlines=(), ax=None, ax_args=None, **kwargs):
  """Plot a step histogram given counts over bins."""
  if ax is None:
    _, ax = plt.subplots()

  # duplicate the first element of `counts` to match bin edges
  counts = np.insert(counts, 0, counts[0])

  ax.fill_between(bins, counts, step="pre", alpha=0.4, **kwargs)  # area shading
  ax.plot(bins, counts, drawstyle="steps", **kwargs)  # lines

  for x in vlines:
    ax.axvline(x, color='r', linestyle='dotted')  # vertical line

  if ax_args is None:
    ax_args = {}

  # heuristically set max y to leave a bit of room
  ymin, ymax = ax_args.get('ylim', [None, None])
  if ymax is None:
    ymax = np.max(counts)
    if ax_args.get('yscale', 'linear') == 'log':
      ymax *= 1.5
    else:
      ymax *= 1.1
      if ymin is None:
        ymin = 0

  if ymax == ymin:
    ymax = None

  ax_args['ylim'] = [ymin, ymax]

  ax.set(**ax_args)
  ax.autoscale(enable=False, axis='x', tight=True)

def plot_neuron_stats(v, spike_times):
  fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(12, 5))

  # membrane voltage trace
  ax1.plot(v[0:100])
  ax1.set(xlabel='Time', ylabel='Voltage')
  # plot spike events
  for x in spike_times:
    if x >= 100:
      break
    ax1.axvline(x, color='red')

  # ISI distribution
  isi = np.diff(spike_times)
  n_bins = np.arange(isi.min(), isi.max() + 2) - .5
  counts, bins = np.histogram(isi, n_bins)
  vlines = []
  if len(isi) > 0:
    vlines = [np.mean(isi)]
  xmax = max(20, int(bins[-1])+5)
  histogram(counts, bins, vlines=vlines, ax=ax2, ax_args={
    'xlabel': 'Inter-spike interval',
    'ylabel': 'Number of intervals',
    'xlim': [0, xmax]
  })
  plt.show()
```

# Section 1: The Linear Integrate-and-Fire Neuron

How does a neuron spike?

A neuron charges and discharges an electric field across its cell membrane. The state of this electric field can be described by the _membrane potential_. The membrane potential rises due to excitation of the neuron, and when it reaches a threshold a spike occurs. The potential resets, and must rise to a threshold again before the next spike occurs.

One of the simplest models of spiking neuron behavior is the linear integrate-and-fire model neuron. In this model, the neuron increases its membrane potential $V_m$ over time in response to excitatory input currents $I$ scaled by some factor $\alpha$:

\begin{align}
  dV_m = {\alpha}I
\end{align}

Once $V_m$ reaches a threshold value a spike is produced, $V_m$ is reset to a starting value, and the process continues.

Here, we will take the starting and threshold potentials as $0$ and $1$, respectively. So, for example, if $\alpha I=0.1$ is constant---that is, the input current is constant---then $dV_m=0.1$, and at each timestep the membrane potential $V_m$ increases by $0.1$ until after $(1-0)/0.1 = 10$ timesteps it reaches the threshold and resets to $V_m=0$, and so on.

Note that we define the membrane potential $V_m$ as a scalar: a single real (or floating point) number. However, a biological neuron's membrane potential will not be exactly constant at all points on its cell membrane at a given time. We could capture this variation with a more complex model (e.g. with more numbers). Do we need to?

The proposed model is a 1D simplification. There are many details we could add to it, to preserve different parts of the complex structure and dynamics of a real neuron. If we were interested in small or local changes in the membrane potential, our 1D simplification could be a problem. However, we'll assume an idealized "point" neuron model for our current purpose.

#### Spiking Inputs

Given our simplified model for the neuron dynamics, we still need to consider what form the input $I$ will take. How should we specify the firing behavior of the presynaptic neuron(s) providing the inputs to our model neuron?

Unlike in the simple example above, where $\alpha I=0.1$, the input current is generally not constant. Physical inputs tend to vary with time. We can describe this variation with a distribution.

We'll assume the input current $I$ over a timestep is due to equal contributions from a non-negative ($\ge 0$) integer number of input spikes arriving in that timestep. Our model neuron might integrate currents from 3 input spikes in one timestep, and 7 spikes in the next timestep. We should see similar behavior when sampling from our distribution.

Given no other information about the input neurons, we will also assume that the distribution has a mean (i.e. mean rate, or number of spikes received per timestep), and that the spiking events of the input neuron(s) are independent in time. Are these reasonable assumptions in the context of real neurons?

A suitable distribution given these assumptions is the Poisson distribution, which we'll use to model $I$:

\begin{align}
  I \sim \mathrm{Poisson}(\lambda)
\end{align}

where $\lambda$ is the mean of the distribution: the average rate of spikes received per timestep.

### Exercise 1: Compute $dV_m$

For your first exercise, you will write the code to compute the change in voltage $dV_m$ (per timestep) of the linear integrate-and-fire model neuron. The rest of the code to handle numerical integration is provided for you, so you just need to fill in a definition for `dv` in the `lif_neuron` function below. The value of $\lambda$ for the Poisson random variable is given by the function argument `rate`.

The [`scipy.stats`](https://docs.scipy.org/doc/scipy/reference/stats.html) package is a great resource for working with and sampling from various probability distributions. We will use the `scipy.stats.poisson` class and its method `rvs` to produce Poisson-distributed random samples. In this tutorial, we have imported this package with the alias `stats`, so you should refer to it in your code as `stats.poisson`.

```python
def lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  """ Simulate a linear integrate-and-fire neuron.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    rate (int): The mean rate of incoming spikes

  """
  # precompute Poisson samples for speed
  exc = stats.poisson(rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []

  ################################################################################
  # Students: compute dv, then comment out or remove the next line
  raise NotImplementedError("Excercise: compute the change in membrane potential")
  ################################################################################

  for i in range(1, n_steps):

    dv = ...

    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

# Uncomment these lines after completing the lif_neuron function
# v, spike_times = lif_neuron()
# plot_neuron_stats(v, spike_times)
```

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W1D1_ModelTypes/solutions/W1D1_Tutorial2_Solution_f8960ca1.py)

*Example output:*

## Interactive Demo: Linear-IF neuron
Like last time, you can now explore how various parametes of the LIF model influence the ISI distribution.

```python
#@title

#@markdown You don't need to worry about how the code works – but you do need to **run the cell** to enable the sliders.

def _lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  exc = stats.poisson(rate).rvs(n_steps)
  v = np.zeros(n_steps)
  spike_times = []
  for i in range(1, n_steps):
    dv = alpha * exc[i]
    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0
  return v, spike_times

@widgets.interact(
  n_steps=widgets.FloatLogSlider(1000.0, min=2, max=4),
  alpha=widgets.FloatLogSlider(0.01, min=-2, max=-1),
  rate=widgets.IntSlider(10, min=5, max=20)
)
def plot_lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  v, spike_times = _lif_neuron(int(n_steps), alpha, rate)
  plot_neuron_stats(v, spike_times)
```

```python
#@title Video 2: Linear-IF models
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='QBD7kulhg4U', width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=QBD7kulhg4U

# Section 2: Inhibitory signals

Our linear integrate-and-fire neuron from the previous section was indeed able to produce spikes. However, our ISI histogram doesn't look much like empirical ISI histograms seen in Tutorial 1, which had an exponential-like shape. What is our model neuron missing, given that it doesn't behave like a real neuron?

In the previous model we only considered excitatory behavior -- the only way the membrane potential could decrease was upon a spike event. We know, however, that there are other factors that can drive $V_m$ down. First is the natural tendency of the neuron to return to some steady state or resting potential. We can update our previous model as follows:

\begin{align}
  dV_m = -{\beta}V_m + {\alpha}I
\end{align}

where $V_m$ is the current membrane potential and $\beta$ is some leakage factor. This is a basic form of the popular Leaky Integrate-and-Fire model neuron (for a more detailed discussion of the LIF Neuron, see the Appendix).

We also know that in addition to excitatory presynaptic neurons, we can have inhibitory presynaptic neurons as well. We can model these inhibitory neurons with another Poisson random variable:

\begin{align}
I = I_{exc} - I_{inh} \\
I_{exc} \sim \mathrm{Poisson}(\lambda_{exc}) \\
I_{inh} \sim \mathrm{Poisson}(\lambda_{inh})
\end{align}

where $\lambda_{exc}$ and $\lambda_{inh}$ are the average spike rates (per timestep) of the excitatory and inhibitory presynaptic neurons, respectively.

### Exercise 2: Compute $dV_m$ with inhibitory signals

For your second exercise, you will again write the code to compute the change in voltage $dV_m$, though now of the LIF model neuron described above. Like last time, the rest of the code needed to handle the neuron dynamics are provided for you, so you just need to fill in a definition for `dv` below.

```python
def lif_neuron_inh(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  """ Simulate a simplified leaky integrate-and-fire neuron with both excitatory
  and inhibitory inputs.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    beta (float): The membrane potential leakage factor
    exc_rate (int): The mean rate of the incoming excitatory spikes
    inh_rate (int): The mean rate of the incoming inhibitory spikes
  """

  # precompute Poisson samples for speed
  exc = stats.poisson(exc_rate).rvs(n_steps)
  inh = stats.poisson(inh_rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []

  ###############################################################################
  # Students: compute dv, then comment out or remove the next line
  raise NotImplementedError("Excercise: compute the change in membrane potential")
  ################################################################################

  for i in range(1, n_steps):

    dv = ...

    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

# Uncomment these lines do make the plot once you've completed the function
#v, spike_times = lif_neuron_inh()
#plot_neuron_stats(v, spike_times)
```

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W1D1_ModelTypes/solutions/W1D1_Tutorial2_Solution_4d9a2677.py)

*Example output:*

## Interactive Demo: LIF + inhibition neuron

```python
#@title
#@markdown **Run the cell** to enable the sliders.
def _lif_neuron_inh(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  """ Simulate a simplified leaky integrate-and-fire neuron with both excitatory
  and inhibitory inputs.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    beta (float): The membrane potential leakage factor
    exc_rate (int): The mean rate of the incoming excitatory spikes
    inh_rate (int): The mean rate of the incoming inhibitory spikes
  """
  # precompute Poisson samples for speed
  exc = stats.poisson(exc_rate).rvs(n_steps)
  inh = stats.poisson(inh_rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []
  for i in range(1, n_steps):
    dv = -beta * v[i-1] + alpha * (exc[i] - inh[i])
    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

@widgets.interact(n_steps=widgets.FloatLogSlider(1000.0, min=2.5, max=4),
                  alpha=widgets.FloatLogSlider(0.5, min=-1, max=1),
                  beta=widgets.FloatLogSlider(0.1, min=-1, max=0),
                  exc_rate=widgets.IntSlider(12, min=10, max=20),
                  inh_rate=widgets.IntSlider(12, min=10, max=20))
def plot_lif_neuron(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  v, spike_times = _lif_neuron_inh(int(n_steps), alpha, beta, exc_rate, inh_rate)
  plot_neuron_stats(v, spike_times)
```

```python
#@title Video 3: LIF + inhibition
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='Aq7JrxRkn2w', width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=Aq7JrxRkn2w

#Summary

In this tutorial we gained some intuition for the mechanisms that produce the observed behavior in our real neural data. First, we built a simple neuron model with excitatory input and saw that it's behavior, measured using the ISI distribution, did not match our real neurons. We then improved our model by adding leakiness and inhibitory input. The behavior of this balanced model was much closer to the real neural data.

# Bonus

### Why do neurons spike?

A neuron stores energy in an electric field across its cell membrane, by controlling the distribution of charges (ions) on either side of the membrane. This energy is rapidly discharged to generate a spike when the field potential (or membrane potential) crosses a threshold. The membrane potential may be driven toward or away from this threshold, depending on inputs from other neurons: excitatory or inhibitory, respectively. The membrane potential tends to revert to a resting potential, for example due to the leakage of ions across the membrane, so that reaching the spiking threshold depends not only on the amount of input ever received following the last spike, but also the timing of the inputs.

The storage of energy by maintaining a field potential across an insulating membrane can be modeled by a capacitor. The leakage of charge across the membrane can be modeled by a resistor. This is the basis for the leaky integrate-and-fire neuron model.

### The LIF Model Neuron

The full equation for the LIF neuron is

\begin{align}
C_{m}\frac{dV_m}{dt} = -(V_m - V_{rest})/R_{m} + I
\end{align}

where $C_m$ is the membrane capacitance, $R_M$ is the membrane resistance, $V_{rest}$ is the resting potential, and $I$ is some input current (from other neurons, an electrode, ...).

In our above examples we set many of these parameters to convenient values ($C_m = R_m = dt = 1$, $V_{rest} = 0$) to focus more on the general behavior of the model. However, these too can be manipulated to achieve different dynamics, or to ensure the dimensions of the problem are preserved between simulation units and experimental units (e.g. with $V_m$ given in millivolts, $R_m$ in megaohms, $t$ in milliseconds).