Table of Contents

1  Exercise 1: Computing Entropy

1.1  Exercise 2: Probabilty Mass Function

2  Interactive Demo: Entropy of neurons

2.1  The foundations for Entropy

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

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

We would like to acknowledge [Steinmetz _et al._ (2019)](https://www.nature.com/articles/s41586-019-1787-x) for sharing their data, a subset of which is used here.

___
# Tutorial Objectives
This is tutorial 3 of a 3-part series on different flavors of models used to understand neural data. In parts 1 and 2 we explored mechanisms that would produce the data. In this tutorial we will explore models and techniques that can potentially explain *why* the spiking data we have observed is produced the way it is.

To understand why different spiking behaviors may be beneficial, we will learn about the concept of entropy. Specifically, we will:

- Write code to compute formula for entropy, a measure of information
- Compute the entropy of a number of toy distributions
- Compute the entropy of spiking activity from the Steinmetz dataset

```python
#@title Video 1: “Why” models
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='OOIDEr1e5Gg', 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=OOIDEr1e5Gg

# 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 plot_pmf(pmf,isi_range):
  """Plot the probability mass function."""
  ymax = max(0.2, 1.05 * np.max(pmf))
  pmf_ = np.insert(pmf, 0, pmf[0])
  plt.plot(bins, pmf_, drawstyle="steps")
  plt.fill_between(bins, pmf_, step="pre", alpha=0.4)
  plt.title(f"Neuron {neuron_idx}")
  plt.xlabel("Inter-spike interval (s)")
  plt.ylabel("Probability mass")
  plt.xlim(isi_range);
  plt.ylim([0, ymax])
```

```python
#@title Download Data
import io
import requests
r = requests.get('https://osf.io/sy5xt/download')
if r.status_code != 200:
  print('Could not download data')
else:
  steinmetz_spikes = np.load(io.BytesIO(r.content), allow_pickle=True)['spike_times']
```

# Section 1: Optimization and Information

Neurons can only fire so often in a fixed period of time, as the act of emitting a spike consumes energy that is depleted and must eventually be replenished. To communicate effectively for downstream computation, the neuron would need to make good use of its limited spiking capability. This becomes an optimization problem:

What is the optimal way for a neuron to fire in order to maximize its ability to communicate information?

In order to explore this question, we first need to have a quantifiable measure for information. Shannon introduced the concept of entropy to do just that, and defined it as

\begin{align}
  H_b(X) &= -\sum_{x\in X} p(x) \log_b p(x)
\end{align}

where $H$ is entropy measured in units of base $b$ and $p(x)$ is the probability of observing the event $x$ from the set of all possible events in $X$. See the Appendix for a more detailed look at how this equation was derived.

The most common base of measuring entropy is $b=2$, so we often talk about *bits* of information, though other bases are used as well e.g. when $b=e$ we call the units *nats*.

First, let's explore how entropy changes between some simple discrete probability distributions. In the rest of this tutorial we will refer to these as probability mass functions (PMF), where $p(x_i)$ equals the $i^{th}$ value in an array, and mass refers to how much of the distribution is contained at that value.

For our first PMF, we will choose one where all of the probability mass is located in the middle of the distribution.

```python
11//2
```

    5

```python
11%2
```

    1

```python
help(np.insert)
```

    Help on function insert in module numpy:

    insert(arr, obj, values, axis=None)
        Insert values along the given axis before the given indices.

        Parameters
        ----------
        arr : array_like
            Input array.
        obj : int, slice or sequence of ints
            Object that defines the index or indices before which `values` is
            inserted.

            .. versionadded:: 1.8.0

            Support for multiple insertions when `obj` is a single scalar or a
            sequence with one element (similar to calling insert multiple
            times).
        values : array_like
            Values to insert into `arr`. If the type of `values` is different
            from that of `arr`, `values` is converted to the type of `arr`.
            `values` should be shaped so that ``arr[...,obj,...] = values``
            is legal.
        axis : int, optional
            Axis along which to insert `values`.  If `axis` is None then `arr`
            is flattened first.

        Returns
        -------
        out : ndarray
            A copy of `arr` with `values` inserted.  Note that `insert`
            does not occur in-place: a new array is returned. If
            `axis` is None, `out` is a flattened array.

        See Also
        --------
        append : Append elements at the end of an array.
        concatenate : Join a sequence of arrays along an existing axis.
        delete : Delete elements from an array.

        Notes
        -----
        Note that for higher dimensional inserts `obj=0` behaves very different
        from `obj=[0]` just like `arr[:,0,:] = values` is different from
        `arr[:,[0],:] = values`.

        Examples
        --------
        >>> a = np.array([[1, 1], [2, 2], [3, 3]])
        >>> a
        array([[1, 1],
               [2, 2],
               [3, 3]])
        >>> np.insert(a, 1, 5)
        array([1, 5, 1, ..., 2, 3, 3])
        >>> np.insert(a, 1, 5, axis=1)
        array([[1, 5, 1],
               [2, 5, 2],
               [3, 5, 3]])

        Difference between sequence and scalars:

        >>> np.insert(a, [1], [[1],[2],[3]], axis=1)
        array([[1, 1, 1],
               [2, 2, 2],
               [3, 3, 3]])
        >>> np.array_equal(np.insert(a, 1, [1, 2, 3], axis=1),
        ...                np.insert(a, [1], [[1],[2],[3]], axis=1))
        True

        >>> b = a.flatten()
        >>> b
        array([1, 1, 2, 2, 3, 3])
        >>> np.insert(b, [2, 2], [5, 6])
        array([1, 1, 5, ..., 2, 3, 3])

        >>> np.insert(b, slice(2, 4), [5, 6])
        array([1, 1, 5, ..., 2, 3, 3])

        >>> np.insert(b, [2, 2], [7.13, False]) # type casting
        array([1, 1, 7, ..., 2, 3, 3])

        >>> x = np.arange(8).reshape(2, 4)
        >>> idx = (1, 3)
        >>> np.insert(x, idx, 999, axis=1)
        array([[  0, 999,   1,   2, 999,   3],
               [  4, 999,   5,   6, 999,   7]])

```python
n_bins = 50  # number of points supporting the distribution
x_range = (0, 1)  # will be subdivided evenly into bins corresponding to points

bins = np.linspace(*x_range, n_bins + 1)  # bin edges

pmf = np.zeros(n_bins)
pmf[len(pmf) // 2] = 1.0  # middle point has all the mass

# Since we already have a PMF, rather than un-binned samples, `plt.hist` is not
# suitable. Instead, we directly plot the PMF as a step function to visualize
# the histogram:
pmf_ = np.insert(pmf, 0, pmf[0])  # this is necessary to align plot steps with bin edges
plt.plot(bins, pmf_, drawstyle="steps")
# `fill_between` provides area shading
plt.fill_between(bins, pmf_, step="pre", alpha=0.4)
plt.xlabel("x")
plt.ylabel("p(x)")
plt.xlim(x_range)
plt.ylim(0, 1);
```

If we were to draw a sample from this distribution, we know exactly what we would get every time. Distributions where all the mass is concentrated on a single event are known as *deterministic*.

How much entropy is contained in a deterministic distribution?

## Exercise 1: Computing Entropy

Your first exercise is to implement a method that computes the entropy of a discrete probability distribution, given its mass function. Remember that we are interested in entropy in units of _bits_, so be sure to use the correct log function.

Recall that $\log(0)$ is undefined. When evaluated at $0$, NumPy log functions (such as `np.log2`) return `np.nan` ("Not a Number"). By convention, these undefined terms— which correspond to points in the distribution with zero mass—are excluded from the sum that computes the entropy.

```python
def entropy(pmf):
  """Given a discrete distribution, return the Shannon entropy in bits.

  This is a measure of information in the distribution. For a totally
  deterministic distribution, where samples are always found in the same bin,
  then samples from the distribution give no more information and the entropy
  is 0.

  For now this assumes `pmf` arrives as a well-formed distribution (that is,
  `np.sum(pmf)==1` and `not np.any(pmf < 0)`)

  Args:
    pmf (np.ndarray): The probability mass function for a discrete distribution
      represented as an array of probabilities.
  Returns:
    h (number): The entropy of the distribution in `pmf`.

  """
  ############################################################################
  # Exercise for students: compute the entropy of the provided PMF
  #   1. Exclude the points in the distribution with no mass (where `pmf==0`).
  #      Hint: this is equivalent to including only the points with `pmf>0`.
  #   2. Implement the equation for Shannon entropy (in bits).
  #  When ready to test, comment or remove the next line
#   raise NotImplementedError("Excercise: implement the equation for entropy")
  ############################################################################

  # reduce to non-zero entries to avoid an error from log2(0)
  pmf = pmf[pmf > 0]

  # implement the equation for Shannon entropy (in bits)
  h = - np.sum(pmf * np.log2(pmf))

  # return the absolute value (avoids getting a -0 result)
  return np.abs(h)

# Uncomment to test your entropy function
print(f"{entropy(pmf):.2f} bits")
```

    0.00 bits

```python
# to_remove solution
def entropy(pmf):
  """Given a discrete distribution, return the Shannon entropy in bits.

  This is a measure of information in the distribution. For a totally
  deterministic distribution, where samples are always found in the same bin,
  then samples from the distribution give no more information and the entropy
  is 0.

  For now this assumes `pmf` arrives as a well-formed distribution (that is,
  `np.sum(pmf)==1` and `not np.any(pmf < 0)`)

  Args:
    pmf (np.ndarray): The probability mass function for a discrete distribution
      represented as an array of probabilities.
  Returns:
    h (number): The entropy of the distribution in `pmf`.
  """
  # reduce to non-zero entries to avoid an error from log2(0)
  pmf = pmf[pmf > 0]
  # implement the equation for shannon entropy (in bits)
  h = -np.sum(pmf * np.log2(pmf))
  # return the absolute value (avoids getting a -0 result)
  return np.abs(h)

print(f"{entropy(pmf):.2f} bits")
```

    0.00 bits

We expect zero surprise from a deterministic distribution. If we had done this calculation by hand, it would simply be

$-1\log_2 1 = -0=0$

Note that changing the location of the peak (i.e. the point and bin on which all the mass rests) doesn't alter the entropy. The entropy is about how predictable a sample is with respect to a distribution. A single peak is deterministic regardless of which point it sits on.

```python
pmf = np.zeros(n_bins)
pmf[2] = 1.0  # arbitrary point has all the mass

pmf_ = np.insert(pmf, 0, pmf[0])
plt.plot(bins, pmf_, drawstyle="steps")
plt.fill_between(bins, pmf_, step="pre", alpha=0.4)
plt.xlabel("x")
plt.ylabel("p(x)")
plt.xlim(x_range)
plt.ylim(0, 1);
```

```python
print(f"{entropy(pmf):.2f} bits")
```

    0.00 bits

What about a distribution with mass split equally between two points?

```python
pmf = np.zeros(n_bins)
pmf[len(pmf) // 3] = 0.5
pmf[2 * len(pmf) // 3] = 0.5

pmf_ = np.insert(pmf, 0, pmf[0])
plt.plot(bins, pmf_, drawstyle="steps")
plt.fill_between(bins, pmf_, step="pre", alpha=0.4)
plt.xlabel("x")
plt.ylabel("p(x)")
plt.xlim(x_range)
plt.ylim(0, 1);
```

```python
print(f"{entropy(pmf):.2f} bits")
```

    1.00 bits

Here, the entropy calculation is

$-(0.5 \log_2 0.5 + 0.5\log_2 0.5)=1$

There is 1 bit of entropy. This means that before we take a random sample, there is 1 bit of uncertainty about which point in the distribution the sample will fall on: it will either be the first peak or the second one.

Likewise, if we make one of the peaks taller (i.e. its point holds more of the probability mass) and the other one shorter, the entropy will decrease because of the increased certainty that the sample will fall on one point and not the other:

$-(0.2 \log_2 0.2 + 0.8\log_2 0.8)\approx 0.72$

Try changing the definition of the number and weighting of peaks, and see how the entropy varies.

If we split the probability mass among even more points, the entropy continues to increase. Let's derive the general form for $N$ points of equal mass, where $p_i=p=1/N$:

\begin{align}
 -\sum_i p_i \log_b p_i&= -\sum_i^N \frac{1}{N} \log_b \frac{1}{N}\\
                       &= -\log_b \frac{1}{N} \\
                       &= \log_b N
\end{align}
$$$$

If we have $N$ discrete points, the _uniform distribution_ (where all points have equal mass) is the distribution with the highest entropy: $\log_b N$. This upper bound on entropy is useful when considering binning strategies, as any estimate of entropy over $N$ discrete points (or bins) must be in the interval $[0, \log_b N]$.

```python
pmf = np.ones(n_bins) / n_bins  # [1/N] * N

pmf_ = np.insert(pmf, 0, pmf[0])
plt.plot(bins, pmf_, drawstyle="steps")
plt.fill_between(bins, pmf_, step="pre", alpha=0.4)
plt.xlabel("x")
plt.ylabel("p(x)")
plt.xlim(x_range);
plt.ylim(0, 1);
```

```python
print(f"{entropy(pmf):.2f} bits")
```

    5.64 bits

Here, there are 50 points and the entropy of the uniform distribution is $\log_2 50\approx 5.64$. If we construct _any_ discrete distribution $X$ over 50 points (or bins) and calculate an entropy of $H_2(X)>\log_2 50$, something must be wrong with our implementation of the discrete entropy computation.

# Section 2: Information, neurons, and spikes

```python
#@title Video 2: Entropy of different distributions
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='o6nyrx3KH20', 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=o6nyrx3KH20

Recall the discussion of spike times and inter-spike intervals (ISIs) from Tutorial 1. What does the information content (or distributional entropy) of these measures say about our theory of nervous systems?

We'll consider three hypothetical neurons that all have the same mean ISI, but with different distributions:

1. Deterministic
2. Uniform
3. Exponential

Fixing the mean of the ISI distribution is equivalent to fixing its inverse: the neuron's mean firing rate. If a neuron has a fixed energy budget and each of its spikes has the same energy cost, then by fixing the mean firing rate, we are normalizing for energy expenditure. This provides a basis for comparing the entropy of different ISI distributions. In other words: if our neuron has a fixed budget, what ISI distribution should it express (all else being equal) to maximize the information content of its outputs?

Let's construct our three distributions and see how their entropies differ.

```python
n_bins = 50
mean_isi = 0.025
isi_range = (0, 0.25)

bins = np.linspace(*isi_range, n_bins + 1)
mean_idx = np.searchsorted(bins, mean_isi)

# 1. all mass concentrated on the ISI mean
pmf_single = np.zeros(n_bins)
pmf_single[mean_idx] = 1.0

# 2. mass uniformly distributed about the ISI mean
pmf_uniform = np.zeros(n_bins)
pmf_uniform[0:2*mean_idx] = 1 / (2 * mean_idx)

# 3. mass exponentially distributed about the ISI mean
pmf_exp = stats.expon.pdf(bins[1:], scale=mean_isi)
pmf_exp /= np.sum(pmf_exp)
```

```python
#@title
#@markdown Run this cell to plot the three PMFs
fig, axes = plt.subplots(ncols=3, figsize=(18, 5))

dists =  [# (subplot title, pmf, ylim)
          ("(1) Deterministic", pmf_single, (0, 1.05)),
          ("(1) Uniform", pmf_uniform, (0, 1.05)),
          ("(1) Exponential", pmf_exp, (0, 1.05))]

for ax, (label, pmf_, ylim) in zip(axes, dists):
  pmf_ = np.insert(pmf_, 0, pmf_[0])
  ax.plot(bins, pmf_, drawstyle="steps")
  ax.fill_between(bins, pmf_, step="pre", alpha=0.4)
  ax.set_title(label)
  ax.set_xlabel("Inter-spike interval (s)")
  ax.set_ylabel("Probability mass")
  ax.set_xlim(isi_range);
  ax.set_ylim(ylim);
```

```python
print(
  f"Deterministic: {entropy(pmf_single):.2f} bits",
  f"Uniform: {entropy(pmf_uniform):.2f} bits",
  f"Exponential: {entropy(pmf_exp):.2f} bits",
  sep="\n",
)
```

    Deterministic: 0.00 bits
    Uniform: 3.32 bits
    Exponential: 3.77 bits

```python
#@title Video 3: Probabilities from histogram
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='e2U_-07O9jo', 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=e2U_-07O9jo

In the previous example we created the PMFs by hand to illustrate idealized scenarios. How would we compute them from data recorded from actual neurons?

One way is to convert the ISI histograms we've previously computed into discrete probability distributions using the following equation:

\begin{align}
p_i = \frac{n_i}{\sum\nolimits_{i}n_i}
\end{align}

where $p_i$ is the probability of an ISI falling within a particular interval $i$ and $n_i$ is the count of how many ISIs were observed in that interval.

### Exercise 2: Probabilty Mass Function

Your second exercise is to implement a method that will produce a probability mass function from an array of ISI bin counts.

To verify your solution, we will compute the probability distribution of ISIs from real neural data taken from the Steinmetz dataset.

```python
neuron_idx = 283

isi = np.diff(steinmetz_spikes[neuron_idx])
bins = np.linspace(*isi_range, n_bins + 1)
counts, _ = np.histogram(isi, bins)
```

```python
def pmf_from_counts(counts):
  """Given counts, normalize by the total to estimate probabilities."""
  ###########################################################################
  # Exercise: Compute the PMF. Remove the next line to test your function
#   raise NotImplementedError("Student excercise: compute the PMF from ISI counts")
  ###########################################################################

  pmf = counts / counts.sum()

  return pmf

# Uncomment when ready to test your function
pmf = pmf_from_counts(counts)
plot_pmf(pmf,isi_range)
```

```python
# to_remove solution
def pmf_from_counts(counts):
  """Given counts, normalize by the total to estimate probabilities."""
  pmf = counts / np.sum(counts)
  return pmf

pmf = pmf_from_counts(counts)
with plt.xkcd():
  plot_pmf(pmf,isi_range)
```

# Section 3: Calculate entropy from a PMF

```python
#@title Video 4: Calculating entropy from pmf
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='Xjy-jj-6Oz0', 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=Xjy-jj-6Oz0

Now that we have the probability distribution for the actual neuron spiking activity, we can calculate its entropy.

```python
print(f"Entropy for Neuron {neuron_idx}: {entropy(pmf):.2f} bits")
```

    Entropy for Neuron 283: 3.36 bits

## Interactive Demo: Entropy of neurons

We can combine the above distribution plot and entropy calculation with an interactive widget to explore how the different neurons in the dataset vary in spiking activity and relative information. Note that the mean firing rate across neurons is not fixed, so some neurons with a uniform ISI distribution may have higher entropy than neurons with a more exponential distribution.

```python
# Test for my first widget
def plot_line(x, y):
    _, ax = plt.subplots(1,1)
    ax.plot(x, y)
    return ax

@widgets.interact(k=widgets.FloatSlider(1, min=0, max=5, step=0.1),
                  b=widgets.FloatSlider(1,min=0, max=5, step=0.1))
def plot_interactive_line(k, b):
    x = np.arange(-1, 1, 0.1)
    y = x * k + b
    ax = plot_line(x, y)
    ax.set(
        xlim=(-1, 1),
        ylim=(-5, 5),
        title=f'k = {k}, b = {b}'
    )
```

    interactive(children=(FloatSlider(value=1.0, description='k', max=5.0), FloatSlider(value=1.0, description='b'…

```python
#@title
#@markdown **Run the cell** to enable the sliders.

def _pmf_from_counts(counts):
  """Given counts, normalize by the total to estimate probabilities."""
  pmf = counts / np.sum(counts)
  return pmf

def _entropy(pmf):
  """Given a discrete distribution, return the Shannon entropy in bits."""
  # remove non-zero entries to avoid an error from log2(0)
  pmf = pmf[pmf > 0]
  h = -np.sum(pmf * np.log2(pmf))
  # absolute value applied to avoid getting a -0 result
  return np.abs(h)

@widgets.interact(neuron=widgets.IntSlider(0, min=0, max=(len(steinmetz_spikes)-1)))
def steinmetz_pmf(neuron):
  """ Given a neuron from the Steinmetz data, compute its PMF and entropy """
  isi = np.diff(steinmetz_spikes[neuron])
  bins = np.linspace(*isi_range, n_bins + 1)
  counts, _ = np.histogram(isi, bins)
  pmf = _pmf_from_counts(counts)

  plot_pmf(pmf,isi_range)
  plt.title(f"Neuron {neuron}: H = {_entropy(pmf):.2f} bits")

```

    interactive(children=(IntSlider(value=0, description='neuron', max=733), Output()), _dom_classes=('widget-inte…

---
# Summary

```python
#@title Video 5: Summary of model types
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='X4K2RR5qBK8', 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=X4K2RR5qBK8

Congratulations! You've finished your first NMA tutorial. In this 3 part tutorial series, we used different types of models to understand the spiking behavior of neurons recorded in the Steinmetz data set.

 - We used "what" models to discover that the ISI distribution of real neurons is closest to an exponential distribution
 - We used "how" models to discover that balanced excitatory and inbhitiory inputs, coupled with a leaky membrane, can give rise to neuronal spiking with exhibiting such an exponential ISI distribution
 - We used "why" models to discover that exponential ISI distributions contain the most information when the mean spiking is constrained

# Bonus

### The foundations for Entropy

In his foundational [1948 paper](https://en.wikipedia.org/wiki/A_Mathematical_Theory_of_Communication) on information theory, Claude Shannon began with three criteria for a function $H$ defining the entropy of a discrete distribution of probability masses $p_i\in p(X)$ over the points $x_i\in X$:
1. $H$ should be continuous in the $p_i$.
  - That is, $H$ should change smoothly in response to smooth changes to the mass $p_i$ on each point $x_i$.
2. If all the points have equal shares of the probability mass, $p_i=1/N$, $H$ should be a non-decreasing function of $N$.
  - That is, if $X_N$ is the support with $N$ discrete points and $p(x\in X_N)$ assigns constant mass to each point, then $H(X_1) < H(X_2) < H(X_3) < \dots$
3. $H$ should be preserved by (invariant to) the equivalent (de)composition of distributions.
  - For example (from Shannon's paper) if we have a discrete distribution over three points with masses $(\frac{1}{2},\frac{1}{3},\frac{1}{6})$, then their entropy can be represented in terms of a direct choice between the three and calculated $H(\frac{1}{2},\frac{1}{3},\frac{1}{6})$. However, it could also be represented in terms of a series of two choices:
    1. either we sample the point with mass $1/2$ or not (_not_ is the other $1/2$, whose subdivisions are not given in the first choice),
    2. if (with probability $1/2$) we _don't_ sample the first point, we sample one of the two remaining points, masses $1/3$ and $1/6$.

    Thus in this case we require that $H(\frac{1}{2},\frac{1}{3},\frac{1}{6})=H(\frac{1}{2},\frac{1}{2}) + \frac{1}{2}H(\frac{1}{3}, \frac{1}{6})$

There is a unique function (up to a linear scaling factor) which satisfies these 3 requirements:

\begin{align}
  H_b(X) &= -\sum_{x\in X} p(x) \log_b p(x)
\end{align}

Where the base of the logarithm $b>1$ controls the units of entropy. The two most common cases are $b=2$ for units of _bits_, and $b=e$ for _nats_.

We can view this function as the expectation of the self-information over a distribution:

$$H_b(X) = \mathbb{E}_{x\in X} \left[I_b(x)\right]$$

$$I_b(x)=-\log_b p(x)$$

Self-information is just the negative logarithm of probability, and is a measure of how surprising an event sampled from the distribution would be. Events with $p(x)=1$ are certain to occur, and their self-information is zero (as is the entropy of the distribution they compose) meaning they are totally unsurprising. The smaller the probability of an event, the higher its self-information, and the more surprising the event would be to observe.


```python
%matplotlib inline
```

# Scaling behavior of global errors

This example demonstrates the accuracy of the various $z$-propagation
algorithms. As test case, the propagation dynamics of a fundamental soliton in
terms of the standard nonlinear Schrödinger equation is considered. For this
particular case, an exact solution is available to which a numerical
approximation can be compared
to.

.. codeauthor:: Oliver Melchert <melchert@iqo.uni-hannover.de>

We first import the functionality needed to perform the sequence of numerical
experiments:

```python
import sys
import numpy as np
import numpy.fft as nfft
from fmas.models import ModelBaseClass
from fmas.config import FTFREQ, FT, IFT, C0
from fmas.solver import SiSSM, SySSM, IFM_RK4IP, LEM_SySSM, CQE
from fmas.grid import Grid
```

Next, we implement a model for the nonlinear Schrödinger equation.  In
particular, we here consider the standard nonlinear Schrödinger equation,
given by

\begin{align}\partial_z u = -i \frac{\beta_2}{2}\partial_t^2 u + i\gamma |u|^2 u,\end{align}

wherein $u = u(z, t)$ represents the slowly varying pulse envelope,
$\beta_2=-1$ is the second order dispersion parameter, and
$\gamma=1$ is the nonlinear parameter:

```python
class NSE(ModelBaseClass):

    def __init__(self, w, b2 = -1.0, gamma = 1.):
        super().__init__(w, 0.5*b2*w*w)
        self.gamma = gamma

    @property
    def Lw(self):
        return 1j*self.beta_w

    def Nw(self, uw):
        ut = IFT(uw)
        return 1j*self.gamma*FT(np.abs(ut)**2*ut)
```

Next, we implement a function that performs a single numerical experiment,
in which a single fundamental soliton is propagated for a specified distance.

The exact single-soliton solution of the above nonlinar Schrödinger equation
is given by

\begin{align}u_{\rm{exact}}(z,t) = \sqrt{P_0} {\rm{sech}}(t/t_0)\,e^{-i\gamma P_0 z/2},\end{align}

with $P_0=|\beta_2|/(\gamma t_0^2)$. We here consider a fundamental
soliton of duration $t_0=1$ and use $u_{\rm{exact}}(0,t)$ as
initial condition.  The propagation is performed up to
$z_{\rm{max}}=\pi/2$, i.e.  for one soliton period.

The function below performs a parameter sweep over a range of step sizes
$\Delta z$, and compares the approximate solution
$u(z_{\rm{max}},t)$ at the final position, obtained for a specified
$z$-propagation algorithm, to the exact solution
$u_{\rm{exact}}(z_{\rm{max}},t)$ at that point. This is done by
computing the average relative intensity error, given by

\begin{align}\epsilon = \frac{ \int \left| |u(z_{\rm{max}},t)|^2 - |u_{\rm{exact}}(z_{\rm{max}},t)|^2 \right|\,{\rm{d}}t}{ \int P_0 {\rm{d}}t}\end{align}

This error measure was also used in [H2007]_ to compare the performance of
different $z$-propagation algorithms. The function then returns
the sequence of step sizes and the corresponding error values:

```python
def determine_error(mode):

    # -- SET AXES
    grid = Grid( t_max = 50., t_num = 2**12)
    t, w = grid.t, grid.w

    # -- INITIALIZATION STAGE
    # ... SET MODEL
    b2 = -1.
    gamma = 1.
    model = NSE(w, b2, gamma)
    # ... SET SOLVER TYPE
    switcher = {
        'SiSSM': SiSSM(model.Lw, model.Nw),
        'SySSM': SySSM(model.Lw, model.Nw),
        'IFM': IFM_RK4IP(model.Lw, model.Nw),
        'LEM': LEM_SySSM(model.Lw, model.Nw),
        'CQE': CQE(model.Lw, model.Nw, del_G=1e-6)
    }
    try:
        my_solver = switcher[mode]
    except KeyError:
        print('NOTE: MODE MUST BE ONE OF', list(switcher.keys()))
        raise
        exit()

    # -- AVERAGE RELATIVE INTENSITY ERROR
    _RI_error = lambda x,y: np.sum(np.abs(np.abs(x)**2-np.abs(y)**2)/x.size/np.max(np.abs(y)**2))

    # -- SET TEST PULSE PROPERTIES (FUNDAMENTAL SOLITON)
    t0 = 1.                       # duration
    P0 = np.abs(b2)/t0/t0/gamma   # peak-intensity
    LD = t0*t0/np.abs(b2)         # dispersion length
    # ... EXACT SOLUTION
    u_exact = lambda z, t: np.sqrt(P0)*np.exp(0.5j*gamma*P0*z)/np.cosh(t/t0)
    # ... INITIAL CONDITION FOR PROPAGATION
    u0_t = u_exact(0.0, t)

    res_dz = []
    res_err = []
    for z_num in [2**n for n in range(5,12)]:
        # ...  PROPAGATE INITIAL CONITION
        my_solver.set_initial_condition(w, FT(u0_t))
        my_solver.propagate(
                z_range = 0.5*np.pi*LD,
                n_steps = z_num,
                n_skip = 8
                )

        # ... KEEP RESULTS
        z_fin = my_solver.z[-1]
        dz =  z_fin/(z_num+1)
        u_t_fin = my_solver.utz[-1]
        u_t_fin_exact = u_exact(z_fin, t)
        res_dz.append(dz)
        res_err.append(_RI_error( u_t_fin, u_t_fin_exact))

        # ... CLEAR DATA FIELDS
        my_solver.clear()

    return np.asarray(res_dz), np.asarray(res_err)
```

Finally, we prepare a figure that shows the scaling behavior of the resulting
relative intensity error for the different propagation algorithms
side-by-side:

```python
import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.colors as col

f, ax = plt.subplots(1, 1, figsize=(8,6))

col1 = col12 = 'k'

dz, err = determine_error("SiSSM")
l1 = ax.plot(dz, err, color=col1, marker='D', markersize=3., markerfacecolor=col1, linewidth=1, label=r'SiSSM')

dz, err = determine_error("SySSM")
l2 = ax.plot(dz, err, color=col1, marker='s', markersize=3., markerfacecolor=col12, linewidth=1., label=r'SySSM')

dz, err = determine_error("IFM")
l3 = ax.plot(dz, err, color=col1, marker='o', markersize=3., markerfacecolor=col12, linewidth=1., label=r'IFM-RK4IP')

col1 = col12 = 'gray'

dz, err = determine_error("LEM")
l4 = ax.plot(dz, err, color=col1, marker='^', markersize=3., markerfacecolor=col12, linewidth=1., dashes=[2,2], mew = 1., label=r'LEM')

dz, err = determine_error("CQE")
l5 = ax.plot(dz, err, color=col1, marker='<', markersize=2., markerfacecolor=col12, linewidth=1., mew= 1., dashes=[2,2], label=r'CQE')

ax.legend()

line = lambda a, b, x: a*x**b
dz_ = np.linspace(2e-3,9e-3,5)
ax.plot(dz_, line(0.010, 1, dz_), linewidth=0.75, color='darkgray')
ax.plot(dz_, line(0.003, 2, dz_), linewidth=0.75, color='darkgray')
ax.plot(dz_, line(0.002, 3, dz_), linewidth=0.75, color='darkgray')
ax.plot(dz_, line(0.01, 4, dz_), linewidth=0.75, color='darkgray')

ax.text( 0.375, 0.80, r'$\propto \Delta z$', transform=ax.transAxes)
ax.text( 0.375, 0.58, r'$\propto \Delta z^2$', transform=ax.transAxes)
ax.text( 0.375, 0.41, r'$\propto \Delta z^3$', transform=ax.transAxes)
ax.text( 0.375, 0.30, r'$\propto \Delta z^4$', transform=ax.transAxes)

dz_lim = (3e-4,0.11)
dz_ticks = (1e-3, 1e-2, 1e-1)
ax.tick_params(axis='x', length=2., pad=2, top=False)
ax.set_xscale('log')
ax.set_xlim(dz_lim)
ax.set_xticks(dz_ticks)
ax.set_xlabel(r"Step size $\Delta z~\mathrm{(\mu m)}$")

err_lim = (0.1e-15,1e-1)
err_ticks = (1e-14,1e-10,1e-6,1e-2)
ax.tick_params(axis='y', length=2., pad=2, top=False)
ax.set_yscale('log')
ax.set_ylim(err_lim)
ax.set_yticks(err_ticks)
ax.set_ylabel(r"Global error $\epsilon$")

plt.show()
```

**References:**

.. [H2007] J. Hult, A Fourth-Order Runge–Kutta in the Inter- action Picture
   Method for Simulating Supercontin- uum Generation in Optical Fibers, IEEE
   J. Light- wave Tech. 25 (2007) 3770,
   https://doi.org/10.1109/JLT.2007.909373.


# $\texttt{GiRaFFEfood}$: Initial data for $\texttt{GiRaFFE}$

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

### NRPy+ Source Code for this module: [GiRaFFEfood_HO/GiRaFFEfood_HO_1D_tests.py](../edit/GiRaFFEfood_HO/GiRaFFEfood_HO_1D_tests.py)

## Introduction:

### Alfvén Wave:

 This is a flat-spacetime test with initial data
\begin{align}
A_x &= 0 \\
A_y &= \left \{ \begin{array}{lll}\gamma_\mu x - 0.015 & \mbox{if} & x \leq -0.1/\gamma_\mu \\
1.15 \gamma_\mu x - 0.03g(x) & \mbox{if} & -0.1/\gamma_\mu \leq x \leq 0.1/\gamma_\mu \\
1.3 \gamma_\mu x - 0.015 & \mbox{if} & x \geq 0.1/\gamma_\mu \end{array} \right. , \\
 A_z = &\ y - \gamma_\mu (1-\mu)x ,
\end{align}
which generates the magnetic field in the wave frame,
\begin{align}
B'^{x'}(x') = &\ 1.0,\ B'^y(x') = 1.0, \\
B'^z(x') = &\ \left \{ \begin{array}{lll} 1.0 & \mbox{if} & x' \leq -0.1 \\
				1.0+0.15 f(x') & \mbox{if} & -0.1 \leq x' \leq 0.1 \\
				1.3 & \mbox{if} & x' \geq 0.1 \end{array} \right. ,
\end{align}
and the electric field in the wave frame,
$$E'^{x'}(x') = -B'^z(0,x') \ \ , \ \ E'^y(x') = 0.0 \ \ , \ \ E'^z(x') = 1.0  .$$

These are converted to the grid frame by
\begin{align}
  B^x(0,x) = &\ B'^{x'}(\gamma_\mu x) , \\
  B^y(0,x) = &\ \gamma_\mu [ B'^y(\gamma_\mu x) - \mu E'^z(\gamma_\mu x) ] , \\
  B^z(0,x) = &\ \gamma_\mu [ B'^z(\gamma_\mu x) + \mu E'^y(\gamma_\mu x) ] ,
\end{align}
and
\begin{align}
  E^x(0,x) = &\ E'^{x'}(\gamma_\mu x) , \\
  E^y(0,x) = &\ \gamma_\mu [ E'^y(\gamma_\mu x) + \mu B'^z(\gamma_\mu x) ] ,\\
  E^z(0,x) = &\ \gamma_\mu [ E'^z(\gamma_\mu x) - \mu B'^y(\gamma_\mu x) ],
\end{align}
and the velocity is given by $$\mathbf{v} = \frac{\mathbf{E} \times \mathbf{B}}{B^2}$$ in flat spacetime. Additionally, $f(x)=1+\sin (5\pi x)$, $-1<\mu<1$ is the wave speed relative to the grid frame and $\gamma_\mu = (1-\mu^2)^{-1/2}$, and $g(x) = \cos (5\pi \gamma_\mu x)/\pi$.

For the eventual purpose of testing convergence, any quantity $Q$ evolves as $Q(t,x) = Q(0,x-\mu t)$

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

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

This module is organized as follows

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

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

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

```python
# Step 0: Import the NRPy+ core modules and set the reference metric to Cartesian
import NRPy_param_funcs as par
import indexedexp as ixp
import grid as gri
import finite_difference as fin
from outputC import *
import loop

import reference_metric as rfm
par.set_parval_from_str("reference_metric::CoordSystem","Cartesian")
rfm.reference_metric()

# Step 1a: Set commonly used parameters.
thismodule = "GiRaFFEfood_HO_1D"
# Set the spatial dimension parameter to 3.
par.set_parval_from_str("grid::DIM", 3)
DIM = par.parval_from_str("grid::DIM")

```

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

The vector potential is given as
\begin{align}
A_x &= 0 \\
A_y &= \left \{ \begin{array}{lll}\gamma_\mu x - 0.015 & \mbox{if} & x \leq -0.1/\gamma_\mu \\
1.15 \gamma_\mu x - 0.03g(x) & \mbox{if} & -0.1/\gamma_\mu \leq x \leq 0.1/\gamma_\mu \\
1.3 \gamma_\mu x - 0.015 & \mbox{if} & x \geq 0.1/\gamma_\mu \end{array} \right. , \\
A_z &= y - \gamma_\mu (1-\mu)x .
\end{align}
First, however, we must set $$\gamma_\mu = (1-\mu^2)^{-1/2}$$ and $$g(x) = \cos (5\pi \gamma_\mu x)/\pi$$.

```python
mu_AW,M_PI = par.Cparameters("REAL",thismodule,["mu_AW","M_PI"]) # The wave speed and pi in C
gammamu = 1/sp.sqrt(1-mu_AW**2)

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

g_AW = sp.cos(5*M_PI*gammamu*x)/M_PI
```

Now, we can define the vector potential. We will create three copies of this variable, because the potential is uniquely defined in three zones. Data for $x \leq -0.1/\gamma_\mu$ shall be referred to as "left", data for $-0.1/\gamma_\mu \leq x \leq 0.1/\gamma_\mu$ as "center", and data for $x \geq 0.1/\gamma_\mu$ as "right".

Starting on the left,
\begin{align}
A_x &= 0 \\
A_y &= \gamma_\mu x - 0.015 \\
A_z &= y - \gamma_\mu (1-\mu)x .
\end{align}

```python
AD = ixp.register_gridfunctions_for_single_rank1("EVOL","AD")
AleftD = ixp.zerorank1()

AleftD[0] = sp.sympify(0)
AleftD[1] = gammamu*x-0.015
AleftD[2] = y-gammamu*(1-mu_AW)*x
```

In the the center,
\begin{align}
A_x &= 0 \\
A_y &= 1.15 \gamma_\mu x - 0.03g(x) \\
A_z &= y - \gamma_\mu (1-\mu)x .
\end{align}

```python
AcenterD = ixp.zerorank1()

AcenterD[0] = sp.sympify(0)
AcenterD[1] = 1.15*gammamu*x-0.03*g_AW
AcenterD[2] = y-gammamu*(1-mu_AW)*x
```

And on the right,
\begin{align}
A_x &= 0 \\
A_y &= 1.3 \gamma_\mu x - 0.015 \\
A_z &= y - \gamma_\mu (1-\mu)x .
\end{align}

```python
ArightD = ixp.zerorank1()

ArightD[0] = sp.sympify(0)
ArightD[1] = 1.3*gammamu*x-0.015
ArightD[2] = y-gammamu*(1-mu_AW)*x
```

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

Now, we will set the magnetic and electric fields that we will need to define the initial velocities. First, we need to define $$f(x)=1+\sin (5\pi x);$$ note that in the definition of $B^i$, we need $f(x')$ where $x'=\gamma_\mu x$.

```python
xprime = gammamu*x
f_AW = 1.0 + sp.sin(5.0*M_PI*xprime)
print(f_AW)
```

    sin(5.0*M_PI*xx0/sqrt(-mu_AW**2 + 1)) + 1.0

We will now set the magnetic field in the wave frame:
\begin{align}
B'^{x'}(x') = &\ 1.0,\ B'^y(x') = 1.0, \\
B'^z(x') = &\ \left \{ \begin{array}{lll} 1.0 & \mbox{if} & x' \leq -0.1 \\
				1.0+0.15 f(x') & \mbox{if} & -0.1 \leq x' \leq 0.1 \\
				1.3 & \mbox{if} & x' \geq 0.1 \end{array} \right. .
\end{align}

```python
BleftpU = ixp.zerorank1()
BleftpU[0] = sp.sympify(1.0)
BleftpU[1] = sp.sympify(1.0)
BleftpU[2] = sp.sympify(1.0)

BcenterpU = ixp.zerorank1()
BcenterpU[0] = sp.sympify(1.0)
BcenterpU[1] = sp.sympify(1.0)
BcenterpU[2] = 1.0 + 0.15*f_AW

BrightpU = ixp.zerorank1()
BrightpU[0] = sp.sympify(1.0)
BrightpU[1] = sp.sympify(1.0)
BrightpU[2] = sp.sympify(1.3)

```

Now, we will set the electric field in the wave frame:
\begin{align}
E'^{x'}(x') &= -B'^z(0,x'), \\
E'^y(x') &= 0.0, \\
E'^z(x') &= 1.0  .
\end{align}

```python
EleftpU = ixp.zerorank1()
EleftpU[0] = -BleftpU[2]
EleftpU[1] = sp.sympify(0.0)
EleftpU[2] = sp.sympify(1.0)

EcenterpU = ixp.zerorank1()
EcenterpU[0] = -BcenterpU[2]
EcenterpU[1] = sp.sympify(0.0)
EcenterpU[2] = sp.sympify(1.0)

ErightpU = ixp.zerorank1()
ErightpU[0] = -BrightpU[2]
ErightpU[1] = sp.sympify(0.0)
ErightpU[2] = sp.sympify(1.0)

```

Next, we must transform the the fields into the grid frame. We'll do the magnetic fields first.
\begin{align}
  B^x(0,x) = &\ B'^{x'}(\gamma_\mu x) , \\
  B^y(0,x) = &\ \gamma_\mu [ B'^y(\gamma_\mu x) - \mu E'^z(\gamma_\mu x) ] , \\
  B^z(0,x) = &\ \gamma_\mu [ B'^z(\gamma_\mu x) + \mu E'^y(\gamma_\mu x) ] ,
\end{align}

```python
BleftU = ixp.zerorank1()
BleftU[0] = BleftpU[0]
BleftU[1] = gammamu*(BleftpU[1]-mu_AW*EleftpU[2])
BleftU[2] = gammamu*(BleftpU[2]+mu_AW*EleftpU[1])

BcenterU = ixp.zerorank1()
BcenterU[0] = BcenterpU[0]
BcenterU[1] = gammamu*(BcenterpU[1]-mu_AW*EcenterpU[2])
BcenterU[2] = gammamu*(BcenterpU[2]+mu_AW*EcenterpU[1])

BrightU = ixp.zerorank1()
BrightU[0] = BrightpU[0]
BrightU[1] = gammamu*(BrightpU[1]-mu_AW*ErightpU[2])
BrightU[2] = gammamu*(BrightpU[2]+mu_AW*ErightpU[1])
print(BrightU[1])
```

    (-1.0*mu_AW + 1.0)/sqrt(-mu_AW**2 + 1)

And now the electric fields:
\begin{align}
  E^x(0,x) = &\ E'^{x'}(\gamma_\mu x) , \\
  E^y(0,x) = &\ \gamma_\mu [ E'^y(\gamma_\mu x) + \mu B'^z(\gamma_\mu x) ] ,\\
  E^z(0,x) = &\ \gamma_\mu [ E'^z(\gamma_\mu x) - \mu B'^y(\gamma_\mu x) ],
\end{align}

```python
EleftU = ixp.zerorank1()
EleftU[0] = EleftpU[0]
EleftU[1] = gammamu*(EleftpU[1]+mu_AW*BleftpU[2])
EleftU[2] = gammamu*(EleftpU[2]-mu_AW*BleftpU[1])

EcenterU = ixp.zerorank1()
EcenterU[0] = EcenterpU[0]
EcenterU[1] = gammamu*(EcenterpU[1]+mu_AW*BcenterpU[2])
EcenterU[2] = gammamu*(EcenterpU[2]-mu_AW*BcenterpU[1])

ErightU = ixp.zerorank1()
ErightU[0] = ErightpU[0]
ErightU[1] = gammamu*(ErightpU[1]+mu_AW*BrightpU[2])
ErightU[2] = gammamu*(ErightpU[2]-mu_AW*BrightpU[1])

```

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

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

```python
import WeylScal4NRPy.WeylScalars_Cartesian as weyl
LeviCivitaSymbolDDD = weyl.define_LeviCivitaSymbol_rank3()

Bleft2 = BleftU[0]*BleftU[0] + BleftU[1]*BleftU[1] + BleftU[2]*BleftU[2]
Bcenter2 = BcenterU[0]*BcenterU[0] + BcenterU[1]*BcenterU[1] + BcenterU[2]*BcenterU[2]
Bright2 = BrightU[0]*BrightU[0] + BrightU[1]*BrightU[1] + BrightU[2]*BrightU[2]

ValenciavU = ixp.register_gridfunctions_for_single_rank1("AUX","ValenciavU")

ValenciavleftU = ixp.zerorank1()
for i in range(DIM):
    for j in range(DIM):
        for k in range(DIM):
            ValenciavleftU[i] += LeviCivitaSymbolDDD[i][j][k] * EleftU[j] * BleftU[k] / Bleft2

ValenciavcenterU = ixp.zerorank1()
for i in range(DIM):
    for j in range(DIM):
        for k in range(DIM):
            ValenciavcenterU[i] += LeviCivitaSymbolDDD[i][j][k] * EcenterU[j] * BcenterU[k] / Bcenter2

ValenciavrightU = ixp.zerorank1()
for i in range(DIM):
    for j in range(DIM):
        for k in range(DIM):
            ValenciavrightU[i] += LeviCivitaSymbolDDD[i][j][k] * ErightU[j] * BrightU[k] / Bright2
```

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

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

```python
# Reset the list of gridfunctions, as registering a gridfunction
#   twice will spawn an error.
gri.glb_gridfcs_list = []

print("vvv Ignore the minor warnings below. vvv")

import GiRaFFEfood_HO.GiRaFFEfood_HO_1D_tests as gfho
gfho.GiRaFFEfood_HO_1D_tests()

print("^^^ Ignore the minor warnings above. ^^^\n")

print("Consistency check between GiRaFFEfood_HO tutorial and NRPy+ module: ALL SHOULD BE ZERO.")

for i in range(DIM):

    print("ValenciavleftU["+str(i)+"] - gfho.ValenciavleftU["+str(i)+"] = " + str(ValenciavleftU[i] - gfho.ValenciavleftU[i]))
    print("AleftD["+str(i)+"] - gfho.AleftD["+str(i)+"] = " + str(AleftD[i] - gfho.AleftD[i]))
    print("ValenciavcenterU["+str(i)+"] - gfho.ValenciavcenterU["+str(i)+"] = " + str(ValenciavcenterU[i] - gfho.ValenciavcenterU[i]))
    print("AcenterD["+str(i)+"] - gfho.AcenterD["+str(i)+"] = " + str(AcenterD[i] - gfho.AcenterD[i]))
    print("ValenciavrightU["+str(i)+"] - gfho.ValenciavrightU["+str(i)+"] = " + str(ValenciavrightU[i] - gfho.ValenciavrightU[i]))
    print("ArightD["+str(i)+"] - gfho.ArightD["+str(i)+"] = " + str(ArightD[i] - gfho.ArightD[i]))

```

    vvv Ignore the minor warnings below. vvv
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::M_PI
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::xmin
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::xmax
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::ymin
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::ymax
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::zmin
    initialize_param() minor warning: Did nothing; already initialized parameter reference_metric::zmax
    initialize_param() minor warning: Did nothing; already initialized parameter GiRaFFEfood_HO_1D::mu_AW
    initialize_param() minor warning: Did nothing; already initialized parameter GiRaFFEfood_HO_1D::M_PI
    ^^^ Ignore the minor warnings above. ^^^

    Consistency check between GiRaFFEfood_HO tutorial and NRPy+ module: ALL SHOULD BE ZERO.
    ValenciavleftU[0] - gfho.ValenciavleftU[0] = 0
    AleftD[0] - gfho.AleftD[0] = 0
    ValenciavcenterU[0] - gfho.ValenciavcenterU[0] = 0
    AcenterD[0] - gfho.AcenterD[0] = 0
    ValenciavrightU[0] - gfho.ValenciavrightU[0] = 0
    ArightD[0] - gfho.ArightD[0] = 0
    ValenciavleftU[1] - gfho.ValenciavleftU[1] = 0
    AleftD[1] - gfho.AleftD[1] = 0
    ValenciavcenterU[1] - gfho.ValenciavcenterU[1] = 0
    AcenterD[1] - gfho.AcenterD[1] = 0
    ValenciavrightU[1] - gfho.ValenciavrightU[1] = 0
    ArightD[1] - gfho.ArightD[1] = 0
    ValenciavleftU[2] - gfho.ValenciavleftU[2] = 0
    AleftD[2] - gfho.AleftD[2] = 0
    ValenciavcenterU[2] - gfho.ValenciavcenterU[2] = 0
    AcenterD[2] - gfho.AcenterD[2] = 0
    ValenciavrightU[2] - gfho.ValenciavrightU[2] = 0
    ArightD[2] - gfho.ArightD[2] = 0

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

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

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

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


```python
#solving problem 1 (hopefully)
```

```python
import random
import sympy as sm
import numpy as np
import matplotlib.pyplot as plt
import ipywidgets as widgets
from scipy import optimize

#setting nice printing and seed
sm.init_printing(use_unicode=True)
random.seed(12848174)
```

```python
def con_w (wage, hum_cap):
    con = wage*hum_cap
    return con

def con_nw(b):
    con = b
    return con
```

```python
def u_con(con,rho):
    utility = (c**(1-rho))/(1-rho)
    return utility
def d_u_work(gamma,l):
    disutility = gamma*l

    return disutility
```

```python
def solution(wage, hum_cap, gamma, rho,b):

    if con_w(wage, hum_cap) => con_nw(b):

```

```python

```

```python
h2_f = (h1 + l1 + 0.5*delta)
c1_f = w*h1*l1

total_u1_sub1 = total_u1.subs(c1,c1_f)
#foc2 = sm.diff(total_u2_sub2,l2)
#foc2
total_u1_sub1
```

```python
#we should not solve for l2 rigth? this is just a dummy of work or not
#sol2 = sm.solve(sm.Eq(foc2,0),l2)[0]
#sol2
h1_sol = sm.solve(sm.Eq(total_u1_sub1,u),h1)[0]
h1_sol
```


```python
import matplotlib.pyplot as plt
import scipy.stats as st
import seaborn as sns
import pandas as pd
from scipy.stats import norm, uniform, expon, t, binom, binom_test, wilcoxon
from scipy.integrate import quad
from sympy.solvers import solve
from sympy import Symbol
import numpy as np
from pandas import Series, DataFrame
```

```python
import matplotlib.pyplot as plt
import pandas as pd
from pandas import DataFrame
import numpy as np
sp2012 = pd.read_table('sp2012.txt')
df = DataFrame(sp2012)
plt.plot(df)
plt.xlabel("Zeit (Tage)")
plt.ylabel("Wert")
plt.title('S&P 500 - Aktienkurs 2012')
plt.show()

```


# Tutorial 2: Principal Component Analysis
**Week 1, Day 5: Dimensionality Reduction**

**By Neuromatch Academy**

__Content creators:__ Alex Cayco Gajic, John Murray

__Content reviewers:__ Roozbeh Farhoudi, Matt Krause, Spiros Chavlis, Richard Gao, Michael Waskom

**Our 2021 Sponsors, including Presenting Sponsor Facebook Reality Labs**

---
# Tutorial Objectives

In this notebook we'll learn how to perform PCA by projecting the data onto the eigenvectors of its covariance matrix.

Overview:
- Calculate the eigenvectors of the sample covariance matrix.
- Perform PCA by projecting data onto the eigenvectors of the covariance matrix.
- Plot and explore the eigenvalues.

To quickly refresh your knowledge of eigenvalues and eigenvectors, you can watch this [short video](https://www.youtube.com/watch?v=kwA3qM0rm7c) (4 minutes) for a geometrical explanation. For a deeper understanding, this [in-depth video](https://www.youtube.com/watch?v=PFDu9oVAE-g&list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab&index=14) (17 minutes) provides an excellent basis and is beautifully illustrated.

```python
# @title Video 1: PCA
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
      def __init__(self, id, page=1, width=400, height=300, **kwargs):
          self.id=id
          src = 'https://player.bilibili.com/player.html?bvid={0}&page={1}'.format(id, page)
          super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id="", width=854, height=480, fs=1)
  print('Video available at https://www.bilibili.com/video/{0}'.format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id="-f6T9--oM0E", width=854, height=480, fs=1, rel=0)
  print('Video available at https://youtube.com/watch?v=' + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

display(out)
```

---
# Setup
Run these cells to get the tutorial started.

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

```python
# @title Figure Settings
import ipywidgets as widgets  # interactive display
%config InlineBackend.figure_format = 'retina'
plt.style.use("https://raw.githubusercontent.com/NeuromatchAcademy/course-content/master/nma.mplstyle")
```

```python
# @title Helper functions

def plot_eigenvalues(evals):
  """
  Plots eigenvalues.

  Args:
      (numpy array of floats) : Vector of eigenvalues

  Returns:
    Nothing.

  """
  plt.figure(figsize=(4, 4))
  plt.plot(np.arange(1, len(evals) + 1), evals, 'o-k')
  plt.xlabel('Component')
  plt.ylabel('Eigenvalue')
  plt.title('Scree plot')
  plt.xticks(np.arange(1, len(evals) + 1))
  plt.ylim([0, 2.5])

def sort_evals_descending(evals, evectors):
  """
  Sorts eigenvalues and eigenvectors in decreasing order. Also aligns first two
  eigenvectors to be in first two quadrants (if 2D).

  Args:
    evals (numpy array of floats)    : Vector of eigenvalues
    evectors (numpy array of floats) : Corresponding matrix of eigenvectors
                                        each column corresponds to a different
                                        eigenvalue

  Returns:
    (numpy array of floats)          : Vector of eigenvalues after sorting
    (numpy array of floats)          : Matrix of eigenvectors after sorting
  """

  index = np.flip(np.argsort(evals))
  evals = evals[index]
  evectors = evectors[:, index]
  if evals.shape[0] == 2:
    if np.arccos(np.matmul(evectors[:, 0],
                           1 / np.sqrt(2) * np.array([1, 1]))) > np.pi / 2:
      evectors[:, 0] = -evectors[:, 0]
    if np.arccos(np.matmul(evectors[:, 1],
                           1 / np.sqrt(2) * np.array([-1, 1]))) > np.pi / 2:
      evectors[:, 1] = -evectors[:, 1]
  return evals, evectors

def plot_data(X):
  """
  Plots bivariate data. Includes a plot of each random variable, and a scatter
  scatter plot of their joint activity. The title indicates the sample
  correlation calculated from the data.

  Args:
    X (numpy array of floats) : Data matrix each column corresponds to a
                                different random variable

  Returns:
    Nothing.
  """

  fig = plt.figure(figsize=[8, 4])
  gs = fig.add_gridspec(2, 2)
  ax1 = fig.add_subplot(gs[0, 0])
  ax1.plot(X[:, 0], color='k')
  plt.ylabel('Neuron 1')
  ax2 = fig.add_subplot(gs[1, 0])
  ax2.plot(X[:, 1], color='k')
  plt.xlabel('Sample Number (sorted)')
  plt.ylabel('Neuron 2')
  ax3 = fig.add_subplot(gs[:, 1])
  ax3.plot(X[:, 0], X[:, 1], '.', markerfacecolor=[.5, .5, .5],
           markeredgewidth=0)
  ax3.axis('equal')
  plt.xlabel('Neuron 1 activity')
  plt.ylabel('Neuron 2 activity')
  plt.title('Sample corr: {:.1f}'.format(np.corrcoef(X[:, 0], X[:, 1])[0, 1]))
  plt.show()

def get_data(cov_matrix):
  """
  Returns a matrix of 1000 samples from a bivariate, zero-mean Gaussian

  Note that samples are sorted in ascending order for the first random
  variable.

  Args:
    var_1 (scalar)                     : variance of the first random variable
    var_2 (scalar)                     : variance of the second random variable
    cov_matrix (numpy array of floats) : desired covariance matrix

  Returns:
    (numpy array of floats)            : samples from the bivariate Gaussian,
                                          with each column corresponding to a
                                          different random variable
  """

  mean = np.array([0, 0])
  X = np.random.multivariate_normal(mean, cov_matrix, size=1000)
  indices_for_sorting = np.argsort(X[:, 0])
  X = X[indices_for_sorting, :]
  return X

def calculate_cov_matrix(var_1, var_2, corr_coef):
  """
  Calculates the covariance matrix based on the variances and
  correlation coefficient.

  Args:
    var_1 (scalar)         :  variance of the first random variable
    var_2 (scalar)         :  variance of the second random variable
    corr_coef (scalar)     :  correlation coefficient

  Returns:
    (numpy array of floats) : covariance matrix
  """
  cov = corr_coef * np.sqrt(var_1 * var_2)
  cov_matrix = np.array([[var_1, cov], [cov, var_2]])
  return cov_matrix

def define_orthonormal_basis(u):
  """
  Calculates an orthonormal basis given an arbitrary vector u.

  Args:
    u (numpy array of floats) : arbitrary 2D vector used for new basis

  Returns:
    (numpy array of floats)   : new orthonormal basis columns correspond to
                                basis vectors
  """

  u = u / np.sqrt(u[0] ** 2 + u[1] ** 2)
  w = np.array([-u[1], u[0]])
  W = np.column_stack((u, w))
  return W

def plot_data_new_basis(Y):
  """
  Plots bivariate data after transformation to new bases. Similar to plot_data
  but with colors corresponding to projections onto basis 1 (red) and
  basis 2 (blue).
  The title indicates the sample correlation calculated from the data.

  Note that samples are re-sorted in ascending order for the first random
  variable.

  Args:
    Y (numpy array of floats) : Data matrix in new basis each column
                                corresponds to a different random variable

  Returns:
    Nothing.
  """

  fig = plt.figure(figsize=[8, 4])
  gs = fig.add_gridspec(2, 2)
  ax1 = fig.add_subplot(gs[0, 0])
  ax1.plot(Y[:, 0], 'r')
  plt.ylabel('Projection \n basis vector 1')
  ax2 = fig.add_subplot(gs[1, 0])
  ax2.plot(Y[:, 1], 'b')
  plt.xlabel('Sample number')
  plt.ylabel('Projection \n basis vector 2')
  ax3 = fig.add_subplot(gs[:, 1])
  ax3.plot(Y[:, 0], Y[:, 1], '.', color=[.5, .5, .5])
  ax3.axis('equal')
  plt.xlabel('Projection basis vector 1')
  plt.ylabel('Projection basis vector 2')
  plt.title('Sample corr: {:.1f}'.format(np.corrcoef(Y[:, 0], Y[:, 1])[0, 1]))
  plt.show()

def change_of_basis(X, W):
  """
  Projects data onto a new basis.

  Args:
    X (numpy array of floats) : Data matrix each column corresponding to a
                                different random variable
    W (numpy array of floats) : new orthonormal basis columns correspond to
                                basis vectors

  Returns:
    (numpy array of floats)   : Data matrix expressed in new basis
  """

  Y = np.matmul(X, W)
  return Y

def plot_basis_vectors(X, W):
  """
  Plots bivariate data as well as new basis vectors.

  Args:
    X (numpy array of floats) : Data matrix each column corresponds to a
                                different random variable
    W (numpy array of floats) : Square matrix representing new orthonormal
                                basis each column represents a basis vector

  Returns:
    Nothing.
  """

  plt.figure(figsize=[4, 4])
  plt.plot(X[:, 0], X[:, 1], '.', color=[.5, .5, .5], label='Data')
  plt.axis('equal')
  plt.xlabel('Neuron 1 activity')
  plt.ylabel('Neuron 2 activity')
  plt.plot([0, W[0, 0]], [0, W[1, 0]], color='r', linewidth=3,
           label='Basis vector 1')
  plt.plot([0, W[0, 1]], [0, W[1, 1]], color='b', linewidth=3,
           label='Basis vector 2')
  plt.legend()
  plt.show()
```

---
# Section 1: Calculate the eigenvectors of the the sample covariance matrix

As we saw in the lecture, PCA represents data in a new orthonormal basis defined by the eigenvectors of the covariance matrix. Remember that in the previous tutorial, we generated bivariate normal data with a specified covariance matrix $\bf \Sigma$, whose $(i,j)$th element is:
\begin{equation}
\Sigma_{ij} = E[ x_i x_j ] - E[ x_i] E[ x_j ] .
\end{equation}
However, in real life we don't have access to this ground-truth covariance matrix. To get around this, we can use the sample covariance matrix, $\bf\hat\Sigma$, which is calculated directly from the data. The $(i,j)$th element of the sample covariance matrix is:
\begin{equation}
 \hat \Sigma_{ij} =  \frac{1}{N_\text{samples}}{\bf x}_i^T {\bf x}_j - \bar {\bf x}_i \bar{\bf x}_j ,
\end{equation}
where ${\bf x}_i = [ x_i(1), x_i(2), \dots,x_i(N_\text{samples})]^T$ is a column vector representing all measurements of neuron $i$, and  $\bar {\bf x}_i$ is the mean of neuron $i$ across samples:
\begin{equation}
\bar {\bf x}_i = \frac{1}{N_\text{samples}} \sum_{k=1}^{N_\text{samples}} x_i(k).
\end{equation}
If we assume that the data has already been mean-subtracted, then we can write the sample covariance matrix in a much simpler matrix form:
\begin{align}
{\bf \hat \Sigma}
&= \frac{1}{N_\text{samples}} {\bf X}^T {\bf X}.
\end{align}

## Exercise 1: Calculation of the covariance matrix

Before calculating the eigenvectors, you must first calculate the sample covariance matrix.

**Steps**
* Complete the function `get_sample_cov_matrix` by first subtracting the sample mean of the data, then calculate $\bf \hat \Sigma$ using the equation above.
* Use `get_data` to generate bivariate normal data, and calculate the sample covariance matrix with your finished `get_sample_cov_matrix`. Compare this estimate to the true covariate matrix using `calculate_cov_matrix`.

```python
help(get_data)
help(calculate_cov_matrix)
```

```python
def get_sample_cov_matrix(X):
  """
  Returns the sample covariance matrix of data X

  Args:
    X (numpy array of floats) : Data matrix each column corresponds to a
                                different random variable

  Returns:
    (numpy array of floats)   : Covariance matrix
  """

  #################################################
  ## TODO for students: calculate the covariance matrix
  # Fill out function and remove
  raise NotImplementedError("Student excercise: calculate the covariance matrix!")
  #################################################

  # Subtract the mean of X
  X = ...
  # Calculate the covariance matrix (hint: use np.matmul)
  cov_matrix = ...

  return cov_matrix

##########################################################
## TODO for students: generate bivariate Gaussian data
# with variances of 1 and a correlation coefficient of 0.8
# compare the true and sample covariance matrices
##########################################################
variance_1 = 1
variance_2 = 1
corr_coef = 0.8

np.random.seed(2020)  # set random seed
# Uncomment below code to test your function
# cov_matrix = calculate_cov_matrix(variance_1, variance_2, corr_coef)
# print(cov_matrix)

# X = get_data(cov_matrix)
# sample_cov_matrix = get_sample_cov_matrix(X)
# print(sample_cov_matrix)
```

SAMPLE OUTPUT
```
[[1.  0.8]
 [0.8 1. ]]
[[0.99315313 0.82347589]
 [0.82347589 1.01281397]]
```

```python
# to_remove solution

def get_sample_cov_matrix(X):
  """
  Returns the sample covariance matrix of data X

  Args:
    X (numpy array of floats) : Data matrix each column corresponds to a
                                different random variable

  Returns:
    (numpy array of floats)   : Covariance matrix
  """

  # Subtract the mean of X
  X = X - np.mean(X, 0)
  # Calculate the covariance matrix (hint: use np.matmul)
  cov_matrix = cov_matrix = 1 / X.shape[0] * np.matmul(X.T, X)

  return cov_matrix

variance_1 = 1
variance_2 = 1
corr_coef = 0.8

np.random.seed(2020)  # set random seed
cov_matrix = calculate_cov_matrix(variance_1, variance_2, corr_coef)
print(cov_matrix)

X = get_data(cov_matrix)
sample_cov_matrix = get_sample_cov_matrix(X)
print(sample_cov_matrix)
```

## Exercise 2: Eigenvectors of the Covariance matrix

Next you will calculate the eigenvectors of the covariance matrix. Plot them along with the data to check that they align with the geometry of the data.

**Steps:**
* Calculate the eigenvalues and eigenvectors of the sample covariance matrix. (**Hint:** use `np.linalg.eigh`, which finds the eigenvalues of a symmetric matrix).
* Use the provided code to sort the eigenvalues in descending order.
* Plot the eigenvectors on a scatter plot of the data, using the function `plot_basis_vectors`.

```python
help(sort_evals_descending)
help(plot_basis_vectors)
```

```python
#################################################
## TO DO for students: Calculate and sort the eigenvalues in descending order
#################################################
# Calculate the eigenvalues and eigenvectors
# evals, evectors = ...
# Sort the eigenvalues in descending order
# evals, evectors = ...

# plot_basis_vectors(X, evectors)
```

```python
# to_remove solution
# Calculate the eigenvalues and eigenvectors
evals, evectors = np.linalg.eigh(cov_matrix)
# Sort the eigenvalues in descending order
evals, evectors = sort_evals_descending(evals, evectors)

with plt.xkcd():
  plot_basis_vectors(X, evectors)
```

---
# Section 2: Perform PCA by projecting data onto the eigenvectors

To perform PCA, we will project the data onto the eigenvectors of the covariance matrix, i.e.:
\begin{equation}
\bf S = X W
\end{equation}
where $\bf S$ is an $N_\text{samples} \times N$ matrix representing the projected data (also called *scores*), and $W$ is an $N\times N$ orthogonal matrix, each of whose columns represents the eigenvectors of the covariance matrix (also called *weights* or *loadings*).

## Exercise 3: PCA implementation

You will now perform PCA on the data using the intuition you have developed so far. Fill in the function below to carry out the steps to perform PCA by projecting the data onto the eigenvectors of its covariance matrix.

**Steps:**
* First subtract the mean.
* Then calculate the sample covariance matrix.
* Then find the eigenvalues and eigenvectors and sort them in descending order.
* Finally project the mean-centered data onto the eigenvectors.

```python
help(change_of_basis)
help(plot_data_new_basis)
```

```python
def pca(X):
  """
  Sorts eigenvalues and eigenvectors in decreasing order.

  Args:
    X (numpy array of floats): Data matrix each column corresponds to a
                               different random variable

  Returns:
    (numpy array of floats)  : Data projected onto the new basis
    (numpy array of floats)  : Vector of eigenvalues
    (numpy array of floats)  : Corresponding matrix of eigenvectors

  """

  #################################################
  ## TODO for students: calculate the covariance matrix
  # Fill out function and remove
  raise NotImplementedError("Student excercise: sort eigenvalues/eigenvectors!")
  #################################################

  # Subtract the mean of X
  X = ...
  # Calculate the sample covariance matrix
  cov_matrix = ...
  # Calculate the eigenvalues and eigenvectors
  evals, evectors = ...
  # Sort the eigenvalues in descending order
  evals, evectors = ...
  # Project the data onto the new eigenvector basis
  score = ...

  return score, evectors, evals

#################################################
## TODO for students: Call the function to calculate the eigenvectors/eigenvalues
#################################################

# Perform PCA on the data matrix X
# score, evectors, evals = ...
# Plot the data projected into the new basis
# plot_data_new_basis(score)
```

```python
# to_remove solution
def pca(X):
  """
  Performs PCA on multivariate data.

  Args:
    X (numpy array of floats) : Data matrix each column corresponds to a
                                different random variable

  Returns:
    (numpy array of floats)   : Data projected onto the new basis
    (numpy array of floats)   : Vector of eigenvalues
    (numpy array of floats)   : Corresponding matrix of eigenvectors

  """

  # Subtract the mean of X
  X = X - np.mean(X, axis=0)
  # Calculate the sample covariance matrix
  cov_matrix = get_sample_cov_matrix(X)
  # Calculate the eigenvalues and eigenvectors
  evals, evectors = np.linalg.eigh(cov_matrix)
  # Sort the eigenvalues in descending order
  evals, evectors = sort_evals_descending(evals, evectors)
  # Project the data onto the new eigenvector basis
  score = change_of_basis(X, evectors)

  return score, evectors, evals

# Perform PCA on the data matrix X
score, evectors, evals = pca(X)
# Plot the data projected into the new basis
with plt.xkcd():
  plot_data_new_basis(score)
```

## Plot and explore the eigenvalues

Finally, we will examine the eigenvalues of the covariance matrix. Remember that each eigenvalue describes the variance of the data projected onto its corresponding eigenvector. This is an important concept because it allows us to rank the PCA basis vectors based on how much variance each one can capture. First run the code below to plot the eigenvalues (sometimes called the "scree plot"). Which eigenvalue is larger?

```python
plot_eigenvalues(evals)
```

## Interactive Demo: Exploration of the correlation coefficient

Run the following cell and use the slider to change the correlation coefficient in the data. You should see the scree plot and the plot of basis vectors update.

**Questions:**
* What happens to the eigenvalues as you change the correlation coefficient?
* Can you find a value for which both eigenvalues are equal?
* Can you find a value for which only one eigenvalue is nonzero?

```python
# @title

# @markdown Make sure you execute this cell to enable the widget!

def refresh(corr_coef=.8):
  cov_matrix = calculate_cov_matrix(variance_1, variance_2, corr_coef)
  X = get_data(cov_matrix)
  score, evectors, evals = pca(X)
  plot_eigenvalues(evals)
  plot_basis_vectors(X, evectors)

_ = widgets.interact(refresh, corr_coef=(-1, 1, .1))
```

---
# Summary
- In this tutorial, we learned that goal of PCA is to find an orthonormal basis capturing the directions of maximum variance of the data. More precisely, the $i$th basis vector is the direction that maximizes the projected variance, while being orthogonal to all previous basis vectors. Mathematically, these basis vectors are the eigenvectors of the covariance matrix (also called *loadings*).
- PCA also has the useful property that the projected data (*scores*) are uncorrelated.
- The projected variance along each basis vector is given by its corresponding eigenvalue. This is important because it allows us rank the "importance" of each basis vector in terms of how much of the data variability it explains. An eigenvalue of zero means there is no variation along that direction so it can be dropped without losing any information about the original data.
- In the next tutorial, we will use this property to reduce the dimensionality of high-dimensional data.

---
# Bonus: Mathematical basis of PCA properties

```python
# @title Video 2: Properties of PCA
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
      def __init__(self, id, page=1, width=400, height=300, **kwargs):
          self.id=id
          src = 'https://player.bilibili.com/player.html?bvid={0}&page={1}'.format(id, page)
          super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id="", width=854, height=480, fs=1)
  print('Video available at https://www.bilibili.com/video/{0}'.format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id="p56UrMRt6-U", width=854, height=480, fs=1, rel=0)
  print('Video available at https://youtube.com/watch?v=' + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

display(out)
```


#### [**NICOLAS CACHANOSKY**](http://www.ncachanosky.com) | Department of Economics | Metropolitan State University of Denver | ncachano@msudenver.edu

# IS-LM MODEL
---

This note illustrates how to code the IS-LM Model in Python. The purpose of the note is to walk through Python applications, not to offer a detailed discussion of the IS-LM Model or to show best coding practices. The note also assumes familiarity with the IS-LM model and a beginner experience with Python.

For a more complete and detailed discussion of Python applications see the material in [Quant Econ](https://quantecon.org/).

---

## TABLE OF CONTENTS
1. [The IS-LM model](#1.-THE-IS-LM-MODEL)
2. [The IS schedule (Investment-Saving)](#2.-THE-IS-SCHEDULE-(INVESTMENT-SAVING))
3. [The LM schedule (Liquidity Preference-Money Supply)](#3.-THE-LM-SCHEDULE-(LIQUIDITY-PREFERENCE-MONEY-SUPPLY))
4. [Equilibrium](#4.-EQUILIBRIUM)
5. [Dynamics](#5.-DYNAMICS)

# 1. THE IS-LM MODEL

The IS-LM, or Hicks-Hansen, model shows the combinations of interest rate (vertical axis) and income (horizontal axis) for which the goods market (IS) and the loan market (LM) are in equilibrium. There is one particular combination of interest rate and income that is consistent with equilibrium in both markets simultaneosuly.

In the graphic version of the model, all the points in the **IS shedule** represent equilibrium in the goods makret. Similarly, all the points in the **LM schedule** represents equlibrium in the loan market.

This model treats the price level as exogenous (given and fixed). In this sense, the IS-LM model is applicable in the context of idle resources or when changes in income has no effect on the price level.

# 2. THE IS SCHEDULE (INVESTMENT-SAVING)

The IS schedule is derived from the equilbrium condition where output $(Y)$ equals spending:

\begin{equation}
Y = C + I + G + (X - Z)
\end{equation}

where $C$ is household consumption, $I$ is private investment, $G$ is the level of government spending, $X$ is exports, and $Z$ is imports. We can treat variables $G = \bar{G}$ and $X = \bar{X}$ as exogenous. The former is defined by policy makers, the latter is given by economic conditions in the rest of the world. A more detailed exposition, such as the Mundell-Fleming model, would also take into consideration the exchange rate, and exprots will also be dependent (to some extent) of domestic economic policy.

Household's consumption follow a keynesian consumption function:

\begin{equation}
 C = a + b(Y - T)
\end{equation}

where $a \geq 0$ is the level of autonomous consumption (indepenent of the level of income), $b \in (0, 1)$ is the marginal propensity to consume, and $T$ is the dollar-amount of taxes.

Assume now a simple linear relationship between investment and the interst rate $i$:

\begin{equation}
    I = \bar{I} - d \cdot i
\end{equation}

where $\bar{I}$ represents the level of investment when $i = 0$ and $d$ is the rate at which $I$ falls when $i$ increases.

We can also assume that imports follow a similar functional form than household's domestic consumption:

\begin{equation}
    Z = \alpha + \beta (Y - T)
\end{equation}

where $\alpha$ is the autonomous level of imports and $\beta$ is the marginal propensity to import.

To derive the IS schedule we need to use the consumption, investment, and import functions and solve for $i$ from the equilibrium condition:

\begin{align}
    Y &= C + I + G + (X - Z)                                                                                \\
    Y &= \underbrace{\left[a + b(Y-T) \right]}_{C} + \underbrace{\left[\bar{I} - d \cdot i \right]}_{I} + \bar{G} + \left[X - \underbrace{\left(\alpha + \beta(Y-T) \right)}_{Z} \right]                                                                         \\
    i_{IS} &= \underbrace{\frac{(a-\alpha)-(b-\beta)T + \bar{I} + \bar{G} + \bar{X}}{d}}_\text{intercept} - \underbrace{\frac{1-b+\beta}{d} }_\text{slope} \cdot Y
\end{align}

Note that the larger $\alpha$ and $\beta$, the lower the intercept. This means that at the same level of $i$ income will be lower. Note also that the intercept and the slope of the IS schedule is sensitive to the value of $d$. The more (less) sensitive investment is to $d$, the more (less) horizontal the IS schedule looks. Finally, note that the IS schedule is a straight line.

The following code plots the IS scheduel using the above information. The first part of the code imports the required Python packages. The second part of the code defines the parameters and arrays. The third part of the code defines and populates the IS schedule. The fourt part of the code plots the IS schedule.

```python
import numpy as np               # Package for scientific computing with Python
import matplotlib.pyplot as plt  # Matplotlib is a 2D plotting library

"2|DEFINE PARAMETERS AND ARRAYS"
# Parameters
Y_size = 100
a = 20                 # Autonomous consumption
b = 0.2                # Marginal propensity to consume
alpha = 5              # Autonomous imports
beta  = 0.2            # Marginal propensity to import
T = 1                  # Taxes
I_bar = 10             # Investment intercept (when i = 0)
G_bar = 8              # Government spending
X_bar = 2              # Exports (given)
d = 5                  # Investment slope wrt to i
# Arrays
Y = np.arange(Y_size)  # Array values of Y

"3|DEFINE AND POPULATE THE IS-SCHEDULE"
def i_IS(a, alpha, b, beta, T, I_bar, G_bar, X_bar, d, Y):
    i_IS = ((a-alpha)-(b-beta)*T + I_bar + G_bar + X_bar - (1-b+beta)*Y)/d
    return i_IS

def Y_IS(a, alpha, b, beta, T, I_bar, G_bar, X_bar, d, i):
    Y_IS = ((a-alpha)-(b-beta)*T + I_bar + G_bar + X_bar - d*i)/(1-b+beta)
    return Y_IS

i = i_IS(a, alpha, b, beta, T, I_bar, G_bar, X_bar, d, Y)

"4|PLOT THE IS-SCHEDULE"
y_max = np.max(i)
x_max = Y_IS(a, alpha, b, beta, T, I_bar, G_bar, X_bar, d, 0)

v = [0, x_max, 0, y_max]                        # Set the axes range
fig, ax = plt.subplots(figsize=(10, 8))
ax.set(title="IS SCHEDULE", xlabel=r'Y', ylabel=r'r')
ax.plot(Y, i, "k-")
ax.yaxis.set_major_locator(plt.NullLocator())   # Hide ticks
ax.xaxis.set_major_locator(plt.NullLocator())   # Hide ticks
plt.axis(v)                                     # Use 'v' as the axes range
plt.show()
```

# 3. THE LM SCHEDULE (LIQUIDITY PREFERENCE-MONEY SUPPLY)

To build the LM schedule we need money supply $(M^S)$ and money demand $(M^D)$. Money supply is an exogenous variable (e.g. a policy decision of the central bank). Following Keynes, the three sources of money demand are (1) precautionary reasons (e.g. save for  a rainy-day), (2) to perform transaction and (3) for speculation reasons. Expressing $M^S$ and $M^D$ in real terms:

\begin{align}
    \frac{M^S}{P} &= \frac{M^S_0}{P}      \\
    \frac{M^D}{P} &= c_1 + c_2 Y - c_3 i
\end{align}

Where $c_1$, $c_2$, and $c_3$ represent the precautionary, transaction, and speculation reasons to demand money. Note that the amount of money needed for transactions depends on the level of income. The interest rate has a negative relationship with money demand due to Keynes' expectations theory (not covered in this note).

Since in equilibrium $M^S = M^D$, the LM schedule can be expressed in the following way:

\begin{align}
    \frac{M^S_0}{P} &= \frac{M^D}{P}                                                     \\
    \frac{M^S_0}{P} &= c_1 + c_2 Y - c_3 i                                               \\
    i_{LM} &= \underbrace{\frac{1}{c_3} \cdot \left(c_1 - \frac{M^S_0}{P} \right)}_\text{intercept} + \underbrace{\frac{c_2}{c_3}}_\text{slope} \cdot Y
\end{align}

Note that if the interest rate has no effect on money demand $(c_3 \to 0)$, then the LM schedule becomes inelastic (vertical).

We can plot the LM schedule in a similar way we did for the IS schedule.

```python
"1|IMPORT PACKAGES"
import numpy as np               # Package for scientific computing with Python
import matplotlib.pyplot as plt  # Matplotlib is a 2D plotting library

"2|DEFINE PARAMETERS AND ARRAYS"
# Parameters
Y_size = 100
c1 = 1000              # Precautionary money demand
c2 = 10                # Transaction money demand
c3 = 10                # Speculation money demand
Ms = 20000             # Nominal money supply
P  = 20                # Price level
# Arrays
Y = np.arange(Y_size)  # Array values of Y

"3|DEFINE AND POPULATE THE LM-SCHEDULE"
def i_LM(c1, c2, c3, Ms, P, Y):
    i_LM = (c1 - Ms/P)/c3 + c2/c3*Y
    return i_LM

i = i_LM(c1, c2, c3, Ms, P, Y)

"4|PLOT THE IS-SCHEDULE"
y_max = np.max(i)

v = [0, Y_size, 0, y_max]                       # Set the axes range
fig, ax = plt.subplots(figsize=(10, 8))
ax.set(title="LM SCHEDULE", xlabel=r'Y', ylabel=r'r')
ax.plot(Y, i, "k-")
ax.yaxis.set_major_locator(plt.NullLocator())   # Hide ticks
ax.xaxis.set_major_locator(plt.NullLocator())   # Hide ticks
plt.axis(v)                                     # Use 'v' as the axes range
plt.show()
```

# 4. EQUILIBRIUM

Note that there is no variable that is on both schedules. This means that any shock shifts only one of the schedules. This construction conveniently separates monetary policy as shifts of the LM schedule from fiscal policy as shifts of the IS schedule. As mentioned above, note that this model has an exogenous (treated as constant) price level.

There is a pair $(Y^*, i^*)$ that produces the "general" equilibrium (contingent to parameters defining positive values for $Y^*$ and $i^*$). If IS = LM, then:

\begin{align}
    i_{LM} &= i_{IS} \\
    \frac{1}{c_3} \left(c_1 - \frac{M^S_0}{P} \right) - \frac{c_2}{c_3} \cdot Y &= \frac{(a-\alpha)-(b-\beta)T + \bar{I} + \bar{G} + X}{d} - \frac{1-b+\beta}{d} \cdot Y \\
    Y^* &= \left[ \frac{(a-\alpha)-(b-\beta)T+\bar{I}+\bar{G}+X}{d} + \frac{1}{c_3} \left(\frac{M^S_0}{P} - c_1 \right) \right] \cdot \left[\frac{1-b+\beta}{d} - \frac{c_2}{c_3} \right]^{-1}
\end{align}

And the interest rate of equilibrium would be:

\begin{align}
    i^* &= \frac{1}{c_3} \left(c_1 - \frac{M^S_0}{P} \right) + \frac{c_2}{c_3} \cdot Y^*  \\
    i^* &= \frac{1}{c_3} \left(c_1 - \frac{M^S_0}{P} \right) + \frac{c_2}{c_3} \cdot \left(\frac{1-b+\beta}{d} - \frac{c_2}{c_3} \right)^{-1} \cdot \left[\frac{(a-\alpha)-(b-\beta)T + \bar{I} + \bar{G} + X}{d} + \frac{1}{c_3} \left(c_1-\frac{M^S_0}{P} \right) \right]
\end{align}

The next code plots the IS-LM model and locates for arrows showing the dynamics of the model when located out of equilibrium. The IS and LM schedules and dynamics are denoted in blue and red respectively.

```python
"1|IMPORT PACKAGES"
import numpy as np               # Package for scientific computing with Python
import matplotlib.pyplot as plt  # Matplotlib is a 2D plotting library

"2|DEFINE PARAMETERS AND ARRAYS"
# Model domain
Y_size = 100
Y   = np.arange(Y_size)     # Array values of Y
# IS Parameters
a = 100                     # Autonomous consumption
b = 0.2                     # Marginal propensity to consume
alpha = 5                   # Autonomous imports
beta  = 0.15                # Marginal propensity to import
T = 1                       # Taxes
I = 10                      # Investment intercept (when i = 0)
G = 20                      # Government spending
X = 5                       # Exports (given)
d = 2                       # Investment slope wrt to i
# LM Parameters
c1 = 2500                   # Precautionary money demand
c2 = 0.75                   # Transaction money demand
c3 = 5                      # Speculation money demand
Ms = 23500                  # Nominal money supply
P  = 10                     # Price level

"3|DEFINE AND POPULATE THE IS AND LM SCHEDULES"
def i_IS(a, alpha, b, beta, T, I, G, X, d, Y):
    i_IS = ((a-alpha)-(b-beta)*T + I + G + X - (1-b+beta)*Y)/d
    return i_IS

def i_LM(c1, c2, c3, Ms, P, Y):
    i_LM = (c1 - Ms/P)/c3 + c2/c3*Y
    return i_LM

iIS = i_IS(a, alpha, b, beta, T, I, G, X, d, Y)
iLM = i_LM(c1, c2, c3, Ms, P, Y)

"4|CALCULATE EQUILIBRUM VALUES"
Y_star1 = ((a-alpha) - (b-beta)*T + I + G + X)/d
Y_star2 = (1/c3) * (c1 - Ms/P)
Y_star3 = (1 - b + beta)/d + (c2/c3)
Y_star  = (Y_star1 - Y_star2)/Y_star3

i_star1 = (1/c3)*(c1 - Ms/P)
i_star2 = (c2/c3)*Y_star
i_star  = i_star1 + i_star2

"5|PLOT THE IS-LM model"
y_max = np.max(iIS)
v = [0, Y_size, 0, y_max]                      # Set the axes range
fig, ax = plt.subplots(figsize=(10, 8))
ax.set(title="IS-LM MODEL", xlabel=r'Y', ylabel=r'r')
ax.plot(Y, iIS, "b-")
ax.plot(Y, iLM, "r-")
ax.yaxis.set_major_locator(plt.NullLocator())  # Hide ticks
ax.xaxis.set_major_locator(plt.NullLocator())  # Hide ticks
ax.arrow(10, i_star, 10,  0, head_length=2, head_width=1, color='b', alpha=0.7)
ax.arrow(10, i_star,  0, -3, head_length=2, head_width=1, color='r', alpha=0.7)
ax.arrow(Y_star, 15, 10,  0, head_length=2, head_width=1, color='b', alpha=0.7)
ax.arrow(Y_star, 15,  0, 10, head_length=2, head_width=1, color='r', alpha=0.7)
ax.arrow(90, i_star,-10,  0, head_length=2, head_width=1, color='b', alpha=0.7)
ax.arrow(90, i_star,  0,  2, head_length=2, head_width=1, color='r', alpha=0.7)
ax.arrow(Y_star, 60,-10,  0, head_length=2, head_width=1, color='b', alpha=0.7)
ax.arrow(Y_star, 60,  0,-10, head_length=2, head_width=1, color='r', alpha=0.7)
plt.plot(Y_star, i_star, 'ko')                # Equilibrium point
plt.axvline(x=Y_star, ls=':', color='k')
plt.axhline(y=i_star, ls=':', color='k')
plt.text(95, 22, "IS", color='b')
plt.text(95, 47, "LM", color='r')
plt.axis(v)                                   # Use 'v' as the axes range
plt.show()
```

# 5. DYNAMICS

The arrows in the above figure shows that the model is stable when out of equilibrium (it converges back to equilibrium from any point). The code below shows the dynamics towards equilbrium starting from four disequilibrium points: A in blue, B in red, C in green, and D in cyan. Ten iterations starting from each point is enough to show the dynamics towards equilibrium.

Horizontal changes $(Y)$ is calculated with the IS function. Vertical changes $(i)$ is calculated with the LM function (see the arrows in the above figure). Starting from a disequilbrium point, the location of the next point is calculated with these two functions. Adding time period $(t = 0, ..., 10)$, where $Y_{0}$ and $i_{0}$ are arbitrary initial values, then:

\begin{align}
    Y_{t} &= \frac{(a-\alpha) - (b-\beta)T + \bar{I} + \bar{G} + X}{1-b+\beta} - \frac{d}{1-b+\beta} \cdot i_{t-1}   \\
    i_{t} &= \frac{1}{c_3} \left(c_1 - \frac{M^S_0}{P} \right) + \frac{c_2}{c_3} \cdot Y_{t-1}
\end{align}

The code below is similar to the ones above with the addition of the four dynamics for each different starting point.

```python
"1|IMPORT PACKAGES"
import numpy as np               # Package for scientific computing with Python
import matplotlib.pyplot as plt  # Matplotlib is a 2D plotting library

"2|DEFINE PARAMETERS AND ARRAYS"
# Model domain
Y_size = 100
Y   = np.arange(Y_size)     # Array values of Y
# IS Parameters
a = 100                     # Autonomous consumption
b = 0.2                     # Marginal propensity to consume
alpha = 5                   # Autonomous imports
beta  = 0.15                # Marginal propensity to import
T = 1                       # Taxes
I = 10                      # Investment intercept (when i = 0)
G = 20                      # Government spending
X = 5                       # Exports (given)
d = 2                       # Investment slope wrt to i
# LM Parameters
c1 = 2500                   # Precautionary money demand
c2 = 0.75                   # Transaction money demand
c3 = 5                      # Speculation money demand
Ms = 23500                  # Nominal money supply
P  = 10                     # Price level

"3|DEFINE AND POPULATE THE IS AND LM SCHEDULES"
def i_IS(a, alpha, b, beta, T, I, G, X, d, Y):
    i_IS = ((a-alpha)-(b-beta)*T + I + G + X - (1-b+beta)*Y)/d
    return i_IS

def i_LM(c1, c2, c3, Ms, P, Y):
    i_LM = (c1 - Ms/P)/c3 + c2/c3*Y
    return i_LM

iIS = i_IS(a, alpha, b, beta, T, I, G, X, d, Y)
iLM = i_LM(c1, c2, c3, Ms, P, Y)

"4|CALCULATE EQUILIBRUM VALUES"
Y_star1 = ((a-alpha) - (b-beta)*T + I + G + X)/d
Y_star2 = (1/c3) * (c1 - Ms/P)
Y_star3 = (1 - b + beta)/d + (c2/c3)
Y_star  = (Y_star1 - Y_star2)/Y_star3

i_star1 = (1/c3)*(c1 - Ms/P)
i_star2 = (c2/c3)*Y_star
i_star  = i_star1 + i_star2

"5|CALCULATE THE DYNAMICS FOR DISEQUILIBRIUM POINTS"
iterations= 10
def Y_IS(a, alpha, b, beta, T, I, G, X, d, i):
    Y_IS = ((a-alpha)-(b-beta)*T+I+G+X)/(1-b+beta) - d/(1-b+beta)*i
    return Y_IS

" |STARTING POINT A"
YA, iA = 10, 50   # Initial values

A_i_LM = np.zeros(iterations)
A_Y_IS = np.zeros(iterations)

A_Y_IS[0] = YA
A_i_LM[0] = iA

for j in range(1, iterations):
    A_Y_IS[j] = Y_IS(a, alpha, b, beta, T, I, G, X, d, A_i_LM[j-1])
    A_i_LM[j] = i_LM(c1, c2, c3, Ms, P, A_Y_IS[j-1])

" |STARTING POINT B"
YB, iB = 80, 55   # Initial values

B_i_LM = np.zeros(iterations)
B_Y_IS = np.zeros(iterations)

B_Y_IS[0] = YB
B_i_LM[0] = iB

for j in range(1, iterations):
    B_Y_IS[j] = Y_IS(a, alpha, b, beta, T, I, G, X, d, B_i_LM[j-1])
    B_i_LM[j] = i_LM(c1, c2, c3, Ms, P, B_Y_IS[j-1])

" |STARTING POINT C"
YC, iC = 70, 20   # Initial values

C_i_LM = np.zeros(iterations)
C_Y_IS = np.zeros(iterations)

C_Y_IS[0] = YC
C_i_LM[0] = iC

for j in range(1, iterations):
    C_Y_IS[j] = Y_IS(a, alpha, b, beta, T, I, G, X, d, C_i_LM[j-1])
    C_i_LM[j] = i_LM(c1, c2, c3, Ms, P, C_Y_IS[j-1])

" |STARTING POINT D"
YD, iD = 30, 25   # Initial values

D_i_LM = np.zeros(iterations)
D_Y_IS = np.zeros(iterations)

D_Y_IS[0] = YD
D_i_LM[0] = iD

for j in range(1, iterations):
    D_Y_IS[j] = Y_IS(a, alpha, b, beta, T, I, G, X, d, D_i_LM[j-1])
    D_i_LM[j] = i_LM(c1, c2, c3, Ms, P, D_Y_IS[j-1])

"6|PLOT THE IS-LM model"
y_max = np.max(iIS)
v = [0, Y_size, 0, y_max]                       # Set the axes range
fig, ax = plt.subplots(figsize=(10, 8))
ax.set(title="IS-LM MODEL", xlabel=r'Y', ylabel=r'r')
ax.plot(Y, iIS, "k-")
ax.plot(Y, iLM, "k-")
ax.yaxis.set_major_locator(plt.NullLocator())   # Hide ticks
ax.xaxis.set_major_locator(plt.NullLocator())   # Hide ticks
plt.plot(Y_star, i_star, "ko")                  # Equilibrium point
plt.plot(YA, iA, "bo")                          # Starting point A
ax.plot(A_Y_IS, A_i_LM, "b--", alpha=0.7)
plt.plot(YB, iB, "ro")                          # Starting point B
ax.plot(B_Y_IS, B_i_LM, "r--", alpha=0.7)
plt.plot(YC, iC, "go")                          # Starting point C
ax.plot(C_Y_IS, C_i_LM, "g--", alpha=0.7)
plt.plot(YD, iD, "co")                          # Starting point C
ax.plot(D_Y_IS, D_i_LM, "c--", alpha=0.7)
plt.axvline(x=Y_star, ls=':', color='k')
plt.axhline(y=i_star, ls=':', color='k')
plt.text(95, 22, "IS")
plt.text(95, 47, "LM")
plt.text(YA  , iA+2, "A", color="b")
plt.text(YB+3, iB  , "B", color="r")
plt.text(YC-3, iC  , "C", color="g")
plt.text(YD  , iD-2, "D", color="c")
plt.axis(v)                                     # Use 'v' as the axes range
plt.show()
```


```python
from Exercise import Exercise, MarkdownBlock
from config import URL, TOKEN
import json

import numpy as np
import sympy as sp
import matplotlib.pyplot as plt
import pandas as pd
plt.rcParams.update({'font.size': 20})

from sklearn.datasets import load_digits
from sklearn import preprocessing
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

Exercise.URL = URL
Exercise.TOKEN = TOKEN
```

### Defining an exercise
Exercises can be instantiated from the `Exercise` class.
An exercise is instantiated by passing a markdown string with the exercise content being displayed to the learner.
This markdown string can contain latex, which must be wrapped in dollar signs ($).
The rendered exercise content can be seen by calling the `display` method on an exercise instance.

```python
m = "What is $1 + 1$?"
e = Exercise(m)
e.add_answer(expression=2, correct=True, feedback="Indeed, $1 + 1 = 2$")
e.add_answer(expression=0, correct=False, feedback="Hmmm, did you compute $1 - 1 = 0$ instead?")
e.add_default_feedback("Please revisit the definition of natural numbers and the ($+$) operator")
e.write("integer_add_static")
e.play()
```

    Published succesfully, preview at: https://www.mscthesis.nl/preview?id=10986424-a5e6-4d62-9267-d61cdc11912f

### Adding answer rules
Answers can be added using the `add_answer` method. User-answers can be simulated for testing purposes by using the `evaluate_answer` method.

```python
e.add_answer(2, True, "That's right! $1 + 1 = 2$")
e.evaluate_answer(2)
```

That's right! $1 + 1 = 2$

```python
e.add_answer(0, False, "Unfortunately that's not right, did you compute $1 - 1 = 0$ instead?")
e.evaluate_answer(0)
```

Unfortunately that's not right, did you compute $1 - 1 = 0$ instead?

Default feedback can be added, shown to the user when no answer rule matches, defaulting to "Incorrect" if not specified.

```python
e.add_default_feedback("Please check the definition of natural numbers and the ($+$) operator")
e.evaluate_answer(3)
```

Please check the definition of natural numbers and the ($+$) operator

Before an exercise can be published, it should be written.

```python
e.write()
e.publish()
# print(json.dumps(e.data, indent=2))
```

    Published succesfully, preview at: https://www.mscthesis.nl/preview?id=b3fc9b31-0910-4215-945c-51c823b17f6c

### Parameterizing an exercise
An exercise can contain parameters by using the `@param` notation in markdown templates. A dict containing SymPy objects should then be passed to a MarkdownBlock to replace the parameters with LaTeX code generated by the MarkdownBlock.

```python
m = r"What is $@a + @b$?"

params = {}
params["a"] = np.random.randint(10)
params["b"] = np.random.randint(10)

e = Exercise(MarkdownBlock(m, params))
e.add_answer(params["a"] + params["b"], True, "That's right!")

e.play()
```

    Published succesfully, preview at: https://www.mscthesis.nl/preview?id=0591e2ad-1bd8-478b-9570-c69a8e00193a

### Exercises with matrices
#### Vector addition

```python
sp.latex(sp.Matrix([1,1,2]))
```

    '\\left[\\begin{matrix}1\\\\1\\\\2\\end{matrix}\\right]'

```python
m = "What is $@v1 + @v2$?"

params = {}
params["v1"] = sp.randMatrix(r=4, c=1, min=0, max=10)
params["v2"] = sp.randMatrix(r=4, c=1, min=0, max=10)
params["ans"] = params["v1"] + params["v2"]

e = Exercise(MarkdownBlock(m, params))
e.add_answer(params["ans"], True, "Correct!")

e.play()
```

    Published succesfully, preview at: https://www.mscthesis.nl/preview?id=add33188-c6ee-479c-b37c-15d24a7089ee

#### Matrix multiplication

```python
s = "What is $@a @b$?"

rows = np.random.randint(1, 4)
columns = np.random.randint(1, 4)

params = {}
params["a"] = sp.randMatrix(r=rows, c=columns, min=0, max=10)
params["b"] = sp.randMatrix(r=columns, c=rows+2, min=0, max=10)
ans = params["a"] * params["b"]

e = Exercise(MarkdownBlock(s, params))
e.add_answer(ans, correct=True, feedback="Yes!")

e.play()
```

    Published succesfully, preview at: https://www.mscthesis.nl/preview?id=b77aa795-38c5-46ef-9658-c757f9555a88

```python
digits = load_digits()
sorted_indices = np.argsort(digits.target)
nums = digits.images[sorted_indices]

def save_image_for(matrix, filename):
    fig, ax = plt.subplots()

    ax.xaxis.set_label_position('top')
    ax.set_xticklabels([i for i in range(0, 9)])

    ax.yaxis.set_label_position('left')
    ax.set_yticklabels([i for i in range(0, 9)])

    # Minor ticks
    ax.set_xticks(np.arange(-.5, 10, 1), minor=True)
    ax.set_yticks(np.arange(-.5, 10, 1), minor=True)

    ax.grid(which='minor', color='black', linestyle='-', linewidth=2)
    ax.matshow(matrix, cmap='binary')

    plt.savefig(filename, dpi=300, bbox_inches='tight')
```

```python
def to_binary(m):
    return np.where(m > 7, 1, 0)

zero_1 = nums[0]
zero_1 = to_binary(zero_1)

zero_2 = nums[1]
zero_2 = to_binary(zero_2)

# save_image_for(zero_1, "zero_1")
# save_image_for(zero_2, "zero_2")
# save_image_for(np.abs(zero_1 - zero_2), "diff")

t = r"""
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 10px;">
    $A = $
    $B = $
    $D = $
</div>

$A = @z1, B = @z2, D = |A - B| = @d, \sum D = @s$
"""

z1 = sp.Matrix(zero_1)
z2 = sp.Matrix(zero_2)
params = {}
params["z1"] = z1
params["z2"] = z2

distance_matrix = np.abs(z1 - z2)
d = sp.Matrix(distance_matrix)
params["d"] = d
params["s"] = np.sum(distance_matrix)

e = Exercise(MarkdownBlock(t, params))
e.display()
e.write()
# e.publish()
```

    $A = $
    $B = $
    $D = $

$A = \left[\begin{matrix}0 & 0 & 0 & 1 & 1 & 0 & 0 & 0\\0 & 0 & 1 & 1 & 1 & 1 & 0 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 1 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 1 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 1 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 0 & 0\\0 & 0 & 1 & 0 & 1 & 1 & 0 & 0\\0 & 0 & 0 & 1 & 1 & 0 & 0 & 0\end{matrix}\right], B = \left[\begin{matrix}0 & 0 & 0 & 1 & 1 & 0 & 0 & 0\\0 & 0 & 1 & 1 & 1 & 1 & 0 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 0 & 0\\0 & 1 & 0 & 0 & 0 & 1 & 0 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 0 & 0\\0 & 0 & 1 & 0 & 0 & 1 & 0 & 0\\0 & 0 & 0 & 1 & 0 & 1 & 0 & 0\\0 & 0 & 0 & 1 & 1 & 0 & 0 & 0\end{matrix}\right], D = |A - B| = \left[\begin{matrix}0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\0 & 0 & 0 & 0 & 0 & 0 & 1 & 0\\0 & 1 & 1 & 0 & 0 & 0 & 1 & 0\\0 & 0 & 0 & 0 & 0 & 0 & 1 & 0\\0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\0 & 0 & 1 & 1 & 1 & 0 & 0 & 0\\0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\end{matrix}\right], \sum D = 8$

#### Matrix indexing

Tasks:
- Create exercise testing vector indexing
- Create exercise testing vector inner-product
- Create exercise testing matrix multiplication

```python
m = r"""
Consider the matrix $A = @a$

What is $a_{@i,@j}$?
"""

a = np.arange(25)
np.random.shuffle(a)
a = a.reshape((5, 5))

params = {}
params["a"] = sp.Matrix(a)
params["i"] = sp.simplify(np.random.randint(6))
params["j"] = sp.simplify(np.random.randint(6))

e = Exercise(MarkdownBlock(m, params))
e.display()

e.html
e.write()
e.publish()
```

Consider the matrix $A = \left[\begin{matrix}4 & 7 & 23 & 13 & 6\\3 & 1 & 16 & 18 & 12\\21 & 14 & 24 & 15 & 20\\22 & 11 & 10 & 17 & 19\\5 & 8 & 0 & 2 & 9\end{matrix}\right]$

What is $a_{3,1}$?

    Published succesfully, preview at: https://www.mscthesis.nl/preview?id=423ee712-071c-44f4-bf67-cdeb9aadd249

```python
m = r"""
<figure>

    <figcaption>Caption goes here</figcaption>
</figure>
"""
def generator():
    e = Exercise(m)
    return e

Exercise.write_multiple(generator, 1, "caption")
```

```python
m = r"""
<div class="d-flex flex-justify-between">
<div class="d-flex flex-column flex-items-center">

    <div>Cap</div>
</div>

<div class="d-flex flex-column flex-items-center">

    <div>Cap</div>
</div>
</div>
"""

p = Exercise(m)
p.write("caption")

```

```python

```