Osnabrück University - Machine Learning (Summer Term 2016) - Prof. Dr.-Ing. G. Heidemann, Ulf Krumnack

# Exercise Sheet 03: Basics of Data Mining

## Introduction

This week's sheet should be solved and handed in before the end of **Sunday, April 29, 2018**. If you need help (and Google and other resources were not enough), feel free to contact your groups designated tutor or whomever of us you run into first. Please upload your results to your group's studip folder.

There are a lot of implementations with fewer theory questions on this sheet, but don't worry: To be able to implement most of the code, you have to understand the theory.

This week's assignments make use of two packages: `numpy` and `matplotlib`. We already expected you to install those as part of sheet 1. If you did not do so, go back to those instructions or just run the following command in the `terminal`/`cmd.exe` to do so. This will also upgrade your current installation.

    conda install jupyter numpy matplotlib

One note about `matplotlib`: If you run code which contains a plot like the cell below, it can sometimes take a while to execute the code and show the results. During that process the invocation count will be shown as a little Asterisk (\*) like this:

    In [*]:

Just be patient for a few seconds. The following cell tests if `numpy` and `matplotlib` are installed and work:

```python
%matplotlib notebook
import importlib

assert importlib.util.find_spec('numpy') is not None , 'numpy not found'
assert importlib.util.find_spec('matplotlib') is not None, 'matplotlib not found'

import numpy as np
import matplotlib.pyplot as plt

figure_intro = plt.figure('Example plot')
plt.plot(np.random.randn(1000, 1))
figure_intro.canvas.draw()
```

    <IPython.core.display.Javascript object>

## Assignment 0: Math recap (vector spaces) [2 Bonus Points]

This exercise is supposed to be very easy and is voluntary. There will be a similar exercise on every sheet.
It is intended to revise some basic mathematical notions that are assumed throughout this class and to allow you to check if you are comfortable with them.
Usually you should have no problem to answer these questions offhand, but if you feel unsure, this is a good time to look them up again. You are always welcome to discuss questions with the tutors or in the practice session.
Also, if you have a (math) topic you would like to recap, please let us know.

**a)** What is a *vector space*? What is the *basis* of a vector space and what is its *dimensionality*? Can you provide examples for finite- and infinite-dimensional vector spaces?

A vector space or linear space is a collection of vectors for which **vector addition** and **scalar multiplication**, that satisfy a set of axioms, hold. A basis is a infinite or finite set of vectors that are *linearly independent* and can be combined to a sum also called *linear combination* to express any vector of that space.

The dimension of a vector space is the cardinality of its basis. An example for a finite-dimensional vector space is the *Euclidean space* $\mathbb{R}^3$, which has the basis

$$\begin{Bmatrix}
\begin{pmatrix}
1\\ 0\\ 0
\end{pmatrix},
\begin{pmatrix}
0\\ 1\\ 0
\end{pmatrix},
\begin{pmatrix}
0\\ 0\\ 1
\end{pmatrix}
\end{Bmatrix}$$

and thereby a dimension of 3. An infinte-dimensional vector space can be found in the *Hilbert space*.

**b)** What is a *linear map*? What is the *image* and the *kernel* of such a map?

A linear mapping is a function that maps between two modules that adheres to certain rules regrading addition and scalar multiplication listed below.

\begin{align}
f(\vec{u}+\vec{v}) &= f(\vec{u}) + f(\vec{v})\\
f(c\vec{u}) &= cf(\vec{u})
\end{align}

The kernel and image of such alinear mapping can be defined as follows:

\begin{align}
ker(f) &= \{ x \in V: f(x) = 0 \}\\
im(f) &= \{ w \in W: f(x) = w, x \in V \}\\
\end{align}

Meaning that the kernel contains all vectors for which the operation f maps to 0 and the image holds all vectors in $W$ which can be mapped to from $V$ using f.

**c)** What is a *matrix*? What is the relation to linear maps?

A matrix is a $n\times m$ collection of values that can be used to perform linear mappings. A popular application of matrices that are linear mappings is the rotation or scaling of 2 or 3-dimensional objects in space (computer graphics).

## Assignment 1: Rosner test [5 Points]

The Rosner test is an iterative procedure to remove outliers of a data set via a z-test. In this exercise you will implement it and apply it to a sample data set.

### a) Outliers

First of all, think about why we use procedures like this and answer the following questions:

What are causes for outliers? And what are our options to deal with them?

A common cause for outliers is the imperfection of measuring technology, leading to incorrect data values. Other causes include the possibility that the data actually exhibits high variation or that the underlying model accounts for the extreme data values, while the data set *at hand* does not.

There are several options available to deal with outliers:
* Remove them
* Weight them according to their z-values (distance to $\mu$ in terms of $\sigma$)
* Remove them and replace the missing data according to a specified procedure

### b) Rosner test

In the following you find a stub for the implementation. The dataset is already generated. Now it is your turn to write the Rosner test and detect the outliers in the data.

`data` is a `np.array` of `[x, y]` coordinates. `outliers` is a list of `[x, y]` coordinates.

```python
%matplotlib notebook
import numpy as np
import matplotlib.pyplot as plt
from math import fabs

# generate dataset
data = list(zip(np.random.uniform(size=100), np.random.normal(size=100)))
data += list(zip(np.random.uniform(size=10), np.random.normal(0, 10, size=10)))
data = np.array(data)
outliers = []

# just to check if everything is pretty
fig_rosner_data = plt.figure('The Dataset')
plt.scatter(data[:,0], data[:,1], marker='x')
plt.axis([0, 1, -20, 20])
fig_rosner_data.canvas.draw()

# Now find the outliers, add them to 'outliers', and remove them from 'data'.
max_iter = 100
for i in range(max_iter):
    median = np.median(data, axis=0)
    dev = np.std(data, axis=0)
    # Find max z item
    z = np.array([[fabs(coord[0]-median[0])/dev[0], fabs(coord[1]-median[1])/dev[1]] for coord in data])
    maxz = np.unravel_index(np.argmax(z, axis=None), z.shape)
    if z[maxz] > 3.5:
        outliers.append(data[maxz[0]])
        data = np.delete(data, maxz[0], axis=0)

# plot results
outliers = np.array(outliers)
fig_rosner = plt.figure('Rosner Result')
plt.scatter(data[:,0], data[:,1], c='b', marker='x', label='cleared data')
plt.scatter(outliers[:,0], outliers[:,1], c='r', marker='x', label='outliers')
plt.axis([0, 1, -20, 20])
plt.legend(loc='lower right');
fig_rosner.canvas.draw()
```

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

## Assignment 2: p-norm [5 Points]

A very well known norm is the euclidean distance. However, it is not the only norm: It is in fact just one of many p-norms where $p = 2$. In this assignment you will take a look at other p-norms and see how they behave.

Implement a function `pnorm` which expects a vector $x \in \mathcal{R}^n$ and a scalar $p \geq 1, p \in \mathcal{R}$ and returns the p-norm of $x$ which is defined as:

$$||x||_p = \left(\sum\limits_{i=1}^n |x_i|^p \right)^{\frac{1}{p}}$$

*Note:* Even though the norm is only defined for $p \geq 1$, values $0 < p < 1$ are still interesting. In that case we can not talk about a norm anymore, as the triangle inequality ($||a|| + ||b|| \geq ||a + b||$) does not hold. We will still take a look at some of these values, so your function should handle them as well.

```python
import numpy as np
from math import fsum, fabs

def pnorm(x, p):
    """
    Calculates the p-norm of x.

    Args:
        x (array): the vector for which the norm is to be computed.
        p (float): the p-value (a positive real number).

    Returns:
        The p-norm of x.
    """
    if p < 0:
        raise ValueError("P-Norm: p must be a positive number")

    if not isinstance(x, list):
        x = [x];

    return pow(fsum([pow(fabs(a),p) for a in x]), 1.0/p)
```

```python
# 1e-10 is 0.0000000001
assert pnorm(1, 2)      - 1          < 1e-10 , "pnorm is incorrect for x = 1, p = 2"
assert pnorm(2, 2)      - 2          < 1e-10 , "pnorm is incorrect for x = 2, p = 2"
assert pnorm([2, 1], 2) - np.sqrt(5) < 1e-10 , "pnorm is incorrect for x = [2, 1], p = 2"
assert pnorm(2, 0.5)    - 2          < 1e-10 , "pnorm is incorrect for x = 2, p = 0.5"
```

Implement another function `pdist` which expects two vectors $x_0 \in \mathcal{R}^n, x_1 \in \mathcal{R}^n$ and a scalar $p \geq 1, p \in \mathcal{R}$ and returns the distance between $x_0$ and $x_1$ on the p-norm defined by $p$. Again handle $0 < p < 1$ as well.

```python
import numpy as np
from math import fabs

def pdist(x0, x1, p):
    """
    Calculates the distance between x0 and x1
    using the p-norm.

    Arguments:
        x0 (array): the first vector.
        x1 (array): the second vector.
        p (float): the p-value (a positive real number).

    Returns:
        The p-distance between x0 and x1.
    """
    if p < 0:
        raise ValueError("P-Dist: p must be a positive number")

    if not isinstance(x0, list):
        x0 = [x0];

    if not isinstance(x1, list):
        x1 = [x1];

    return pnorm([a-b for a,b in zip(x0,x1)], p)
```

```python
# 1e-10 is 0.0000000001
assert pdist(1, 2, 2)           - 1          < 1e-10 , "pdist is incorrect for x0 = 1, x1 = 2, p = 2"
assert pdist(2, 5, 2)           - 3          < 1e-10 , "pdist is incorrect for x0 = 2, x1 = 5, p = 2"
assert pdist([2, 1], [1, 2], 2) - np.sqrt(2) < 1e-10 , "pdist is incorrect for x0 = [2, 1], x1 = [1, 2], p = 2"
assert pdist([2, 1], [0, 0], 2) - np.sqrt(5) < 1e-10 , "pdist is incorrect for x0 = [2, 1], x1 = [0, 0], p = 2"
assert pdist(2, 0, 0.5)         - 2          < 1e-10 , "pdist is incorrect for x0 = 2, x1 = 0, p = 0.5"
```

Now we will compare some different p-norms. Below is part of a code to plot data in nice scatter plots.

Your task is to calculate the data to plot. The variable `data` is currently simply filled with zeros. Instead, fill it as follows:

- Use the function `np.linspace()` to create a vector of `50` evenly distributed values between `-100` and `100` (inclusively).
- Fill `data`: Data is basically the cartesian product of the vector you created before with itself filled up with each value's norm. It should have 2500 rows. Each of the 2500 rows should contain `[x, y, d]`, where `x` is the x coordinate and `y` the y coordinate of a point, and `d` the p-norm of `(x, y)`. Use either `pnorm` or `pdist` to calculate `d`.
- Normalize the data in `data[:,2]` (i.e. all d-values) so that they are between 0 and 1.

Run your code and take a look at your results. Darker colors mean that a value is closer to the center (0, 0) according to the p-norm used.

*Hint:* To give you an idea of how `data` should look like, here is an example for three evenly distributed values between `-1` and `1` and a p-norm with `p = 2`.

Before normalization of the d-column:

```python
data = np.array([[-1.         -1.          1.41421356]
                 [-1.          0.          1.        ]
                 [-1.          1.          1.41421356]
                 [ 0.         -1.          1.        ]
                 [ 0.          0.          0.        ]
                 [ 0.          1.          1.        ]
                 [ 1.         -1.          1.41421356]
                 [ 1.          0.          1.        ]
                 [ 1.          1.          1.41421356]])
```

After normalization of the d-column:

```python
data = np.array([[-1.         -1.          1.        ]
                 [-1.          0.          0.70710678]
                 [-1.          1.          1.        ]
                 [ 0.         -1.          0.70710678]
                 [ 0.          0.          0.        ]
                 [ 0.          1.          0.70710678]
                 [ 1.         -1.          1.        ]
                 [ 1.          0.          0.70710678]
                 [ 1.          1.          1.        ]])
```

```python
%matplotlib notebook
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ColorConverter
from itertools import product

color = ColorConverter()
figure_norms = plt.figure('p-norm comparison')

# create the linspace vector
ls = np.linspace(-100, 100)

assert len(ls) == 50 , 'ls should be of length 50.'
assert (min(ls), max(ls)) == (-100, 100) , 'ls should range from -100 to 100, inclusively.'

for i, p in enumerate([1/8, 1/4, 1/2, 1, 1.5, 2, 4, 8, 128]):
    # Create a numpy array containing useful values instead of zeros
    # tmp_data = np.array([x for x in product(ls, ls)])
    data = np.array([[x[0], x[1], pnorm([x[0], x[1]], p)] for x in product(ls, ls)])

    normalize = np.amax(data[:,2])
    data[:,2] /= normalize

    assert all(data[:,2] <= 1), 'The third column should be normalized.'

    # Plot the data.
    colors = [color.to_rgb((1, 1-a, 1-a)) for a in data[:,2]]
    a = plt.subplot(3, 3, i + 1)
    plt.scatter(data[:,0], data[:,1], marker='.', color=colors)
    a.set_ylim([-100, 100])
    a.set_xlim([-100, 100])
    a.set_title('{:.3g}-norm'.format(p))
    a.set_aspect('equal')
    plt.tight_layout()
    figure_norms.canvas.draw()
```

    <IPython.core.display.Javascript object>

## Assignment 3: Expectation Maximization [10 Points]

In this assignment you will implement the Expectation Maximization algorithm (EM) for 1D data sets.

As some parts of this exercise would require some more knowledge of Python than what was already discussed in the practice sessions we built a small number of templates for you to use. However, if you prefer to do so you are also allowed to just go ahead and implement everything yourself! **Don't forget [task b)](#b%29-EM-and-missing-values)**!

### a) Implement Expectation Maximization

Use the next cell to implement your own solution or, if you want some more guidance, skip the next cell and continue the exercise at  [Step 1) Load the data](#Step-1%29-Load-the-data).

Here is an overview of what you have to do:

**1) Load the data:**

Load the provided data set. It is stored in `em_normdistdata.txt`. We call the set $X$ and each individual data $x \in X$.

**2) Initialize EM:**

Initialize three normal distributions whose parameters will be changed iteratively by the EM to converge close to the original distributions.

Each normal distribution $j$ has three parameters: $\mu_j$ (the mean), $\sigma_j$ (the standard deviation), $\alpha_j$ (the proportion of the normal distribution in the mixture, that means $\sum\limits_j\alpha_j=1$).

Initialize the three parameters using three random partitions $S_j$ of the data set. Calculate each $\mu_j$ and $\sigma_j$ and set $\alpha_j = \frac{|S_j|}{|X|}$.

**3) Implement the expectation step:**

Perform a soft classification of the data samples with the three normal distributions. That means: Calculate the likelihood that a data sample $x_i$ belongs to distribution $j$ given parameters $\mu_j$ and $\sigma_j$. Or in other words, what is the likelihood of $x_i$ to be drawn from $N_j(\mu_j, \sigma_j)$? When you got the likelihood, weight the result by $\alpha_j$.

As a last step normalize the results such that the likelihoods of a data sample $x_i$ sum up to $1$.

**4) Implement the maximization step:**

In the maximization step each $\mu_j$, $\sigma_j$ and $\alpha_j$ is updated. First calculate the new means:

$$\mu_j = \frac{1}{\sum\limits_{i=1}^{|X|} p_{ij}} \sum\limits_{i=1}^{|X|} p_{ij}x_i$$

That means $\mu_j$ is the weighted mean of all samples, where the weight is their likelihood of belonging to distribution $j$.

Then calculate the new $\sigma_j$. Each new $\sigma_j$ is the standard deviation of the normal distribution with the new $\mu_j$, so for the calculation you already use the new $\mu_j$:

$$\sigma_j = \sqrt{ \frac{1}{\sum\limits_{i=1}^{|X|} p_{ij}} \sum\limits_{i=1}^{|X|} p_{ij} \left(x_i - \mu_j\right)^2 }$$

To calculate the new $\alpha_j$ for each distribution, just take the mean of $p_j$ for each normal distribution $j$.

**5) Perform the complete EM and plot your results:**

Build a loop around the iterative procedure of expectation and maximization which stops when the changes in all $\mu_j$ and $\sigma_j$ are sufficiently small enough.

Plot your results after each step and mark which data points belong to which normal distribution. If you don't get it to work, just plot your final solution of the distributions.

```python
# Free space to implement your own solution -- either use this OR use the following step by step guide.
# You may use scipy.stats.norm.pdf for your own implementation.

```

#### Step 1) Load the data

Load the provided data set. It is stored in `em_normdistdata.txt`. We call the set $X$ and each individual data $x \in X$.

*Hint:* Figure out a way on how numpy can load text data.

```python
import numpy as np

def load_data(file_name):
    """
    Loads the data stored in file_name into a numpy array.
    """
    return np.loadtxt(file_name)

assert load_data('em_normdistdata.txt').shape == (200,) , "The data was not properly loaded."
```

*Optional:* The data consists of 200 data points drawn from three normal distributions. To get a feeling for the data set you can plot the data with the following cell. Change the number of bins to get a rough idea of how the three distributions might look like.

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

data = load_data('em_normdistdata.txt')

fig_data_test = plt.figure('Data overview')
plt.hist(data, bins=5)
fig_data_test.canvas.draw()
```

    <IPython.core.display.Javascript object>

#### Step 2) Initialize EM

Below is a class definition `NormPDF` which represents the probability density function (pdf) of the normal distribution with an additional parameter $\alpha$. The class is explained in the next cells.

```python
import numpy as np
class NormPDF():
    """
    A representation of the probability density function of the normal distribution
    for the EM Algorithm.
    """

    def __init__(self, mu=0, sigma=1, alpha=1):
        """
        Initializes the normal distribution with mu, sigma and alpha.
        The defaults are 0, 1, and 1 respectively.
        """
        self.mu = mu
        self.sigma = sigma
        self.alpha = alpha

    def __call__(self, x):
        """
        Returns the evaluation of this normal distribution at x.
        Does not take alpha into account!
        """
        return np.exp(-(x - self.mu) ** 2 / (2 * self.sigma ** 2)) / (np.sqrt(np.pi * 2) * self.sigma)

    def __repr__(self):
        """
        A simple string representation of this instance.
        """
        return 'NormPDF({self.mu:.2f},{self.sigma:.2f},{self.alpha:.2f})'.format(self=self)
```

The class `NormPDF` offers several class methods: `__init__`, `__call__`, `__repr__`. They are all special Python functions which are overloaded so they can be used in a nice way. Note that all methods take as the first parameter `self`: this is just the python way of passing the instance itself to the method so that it becomes possible to access its data. You can always ignore it for now and just assume that the methods only need the parameters which follow.

`__init__`: This is the constructor. When a new instance of the class is created this method is used. It takes the parameters `mu`, `sigma`, and `alpha`. Note that if you leave out parameters, they will be set to some default values.
So you can create `NormPDF` instances like this:

```python
a = NormPDF()             # No parameters: mu = 0, sigma = 1, alpha = 1
b = NormPDF(1)            # mu = 1, sigma = 1, alpha = 1
c = NormPDF(1, alpha=0.4) # skips sigma but sets alpha, thus: mu = 1, sigma = 1, alpha = 0.4
d = NormPDF(0, 0.5)       # mu = 0, sigma = 0.5, alpha = 1
e = NormPDF(0, 0.5, 0.9)  # mu = 0, sigma = 0.5, alpha = 0.9
```

`__call__`: This is a very cool feature of Python. By implementing this method one can make an instance *callable*. That basically means one can use it as if it was a function. The `NormPDF` instances can be called with an x value (or a numpy array of x values) to get the evaluation of the normal distribution at x.

```python
normpdf = NormPDF()
print(normpdf(0))
print(normpdf(0.5))
print(normpdf(np.linspace(-2, 2, 10)))
```

    0.3989422804014327
    0.3520653267642995
    [0.05399097 0.11897819 0.21519246 0.31944801 0.38921247 0.38921247
     0.31944801 0.21519246 0.11897819 0.05399097]

`__repr__`: This method will be used in Python when one calls `repr(NormPDF())`. As long as `__str__` is not implemented (which you saw in last week's sheet) `str(NormPDF())` will also use this method. This comes in handy for printing:

```python
normpdf1 = NormPDF()
normpdf2 = NormPDF(1, 0.5, 0.9)
print(normpdf1)
print([normpdf1, normpdf2])
```

    NormPDF(0.00,1.00,1.00)
    [NormPDF(0.00,1.00,1.00), NormPDF(1.00,0.50,0.90)]

It is also possible to change the values of an instance of the NormPDF:

```python
normpdf1 = NormPDF()
print(normpdf1)
print(normpdf1(np.linspace(-2, 2, 10)))

normpdf1.mu = 1
normpdf1.sigma = 2
normpdf1.alpha = 0.9
print(normpdf1)
print(normpdf1(np.linspace(-2, 2, 10)))
```

    NormPDF(0.00,1.00,1.00)
    [0.05399097 0.11897819 0.21519246 0.31944801 0.38921247 0.38921247
     0.31944801 0.21519246 0.11897819 0.05399097]
    NormPDF(1.00,2.00,0.90)
    [0.0647588  0.08817395 0.11427077 0.14095594 0.16549503 0.18494385
     0.19671986 0.19916355 0.19192205 0.17603266]

Now that you know how the `NormPDF` class works, it is time for the implementation of the initialization function. Here is the task again:

Write a function `gaussians = initialize_EM(data, num_distributions)` to initialize the EM.

Each normal distribution $j$ has three parameters: $\mu_j$ (the mean), $\sigma_j$ (the standard deviation), $\alpha_j$ (the proportion of the normal distribution in the mixture, that means $\sum\limits_j\alpha_j=1$).
Initialize the three parameters using three random partitions $S_j$ of the data set. Calculate each $\mu_j$ and $\sigma_j$ and set $\alpha_j = \frac{|S_j|}{|X|}$.

```python
def initialize_EM(data, num_distributions):
    """
    Initializes the EM algorithm by calculating num_distributions NormPDFs
    from a random partitioning of data. I.e., the data set is randomly
    divided into num_distribution parts, and each part is used to initialize
    mean, standard deviation and alpha parameter of a NormPDF object.

    Args:
        data (array): A collection of data.
        num_distributions (int): The number of distributions to return.

    Returns:
        A list of num_distribution NormPDF objects, initialized from a
        random partioning of the data.
    """
    gaussians = []
    size = len(data)

    # Shuffle and split data to get 'randomized' partitioning
    np.random.shuffle(data)
    subsets = np.array_split(data, num_distributions)
    # For each subset, generate a pdf using the contained data
    for aset in subsets:
        gaussians.append(NormPDF(np.mean(aset), np.std(aset), len(aset)/size))

    return gaussians

normpdfs_ = initialize_EM(np.linspace(-1, 1, 100), 2)
assert len(normpdfs_) == 2, "The number of initialized distributions is not correct."
# 1e-10 is 0.0000000001
assert abs(1 - sum([normpdf.alpha for normpdf in normpdfs_])) < 1e-10 , "Sum of all alphas is not 1.0!"
```

#### Step 3) Implement the expectation step

Perform a soft classification of the data samples with the normal distributions. That means: Calculate the likelihood that a data sample $x_i$ belongs to distribution $j$ given parameters $\mu_j$ and $\sigma_j$. Or in other words, what is the likelihood of $x_i$ to be drawn from $N_j(\mu_j, \sigma_j)$? When you got the likelihood, weight the result by $\alpha_j$.

As a last step normalize the results such that the likelihoods of a data sample $x_i$ sum up to $1$.

*Hint:* Store the data in a different array before you normalize it to not run into problems with partly normalized data.

```python
from math import fsum

def expectation_step(gaussians, data):
    """
    Performs the expectation step of the EM.

    Args:
        gaussians (list): A list of NormPDF objects.
        data (array): The data vector.

    Returns:
        An array of shape (len(data), len(gaussians))
        which contains normalized likelihoods for each sample
        to denote to which of the normal distributions it
        most likely belongs to.
    """
    tmp = np.array([[g(entry)*g.alpha for g in gaussians] for entry in data])
    expectation = np.array(tmp)

    for i in range(len(data)):
        whole = fsum(tmp[i,:])
        expectation[i,:] = [x/whole for x in tmp[i,:]]

    return expectation

assert expectation_step([NormPDF(), NormPDF()], np.linspace(-2, 2, 100)).shape == (100, 2) , "Shape is not correct!"
```

#### Step 4) Implement the maximization step

In the maximization step each $\mu_j$, $\sigma_j$ and $\alpha_j$ is updated. First calculate the new means:

$$\mu_j = \frac{1}{\sum\limits_{i=1}^{|X|} p_{ij}} \sum\limits_{i=1}^{|X|} p_{ij}x_i$$

That means $\mu_j$ is the weighted mean of all samples, where the weight is their likelihood of belonging to distribution $j$.

Then calculate the new $\sigma_j$. Each new $\sigma_j$ is the standard deviation of the normal distribution with the new $\mu_j$, so for the calculation you already use the new $\mu_j$:

$$\sigma_j = \sqrt{ \frac{1}{\sum\limits_{i=1}^{|X|} p_{ij}} \sum\limits_{i=1}^{|X|} p_{ij} \left(x_i - \mu_j\right)^2 }$$

To calculate the new $\alpha_j$ for each distribution, just take the mean of $p_j$ for each normal distribution $j$.

**Caution:** For the next step it is necessary to know how much all $\mu$ and $\sigma$ changed. For that the function `maximization_step` should return a numpy array of those (absolute) changes. For example if $\mu_0$ changed from 0.1 to 0.15, $\sigma_0$ from 1 to 0.9, $\mu_1$ from 0.5 to 0.6, and $\sigma_1$, $\mu_2$, and $\sigma_2$ stayed the same, we expect the function to return `np.array([0.05, 0.1, 0.1, 0, 0, 0])` (however, the order is not important).

```python
from math import sqrt, fabs, fsum

def weighted_mean(data, pij):
    """
    Calculates the new weighted mean

    Args:
        data (array): the data vector
        pij (array): expected values for the data

    Returns:
        The new weighted mean for the distribution
    """
    return fsum([x*y for x,y in zip(data,pij)])/fsum(pij)

def std_dev(mu, data, pij):
    """
    Calculates the new standard deviation

    Args:
        mu (float): mean
        data (array): the data vector
        pij (array): expected values for the data

    Returns:
        The new weighted mean for the distribution
    """
    return sqrt(fsum([y*pow(x-mu, 2) for x,y in zip(data,pij)])/fsum(pij))

def maximization_step(gaussians, data, expectation):
    """
    Performs the maximization step of the EM.
    Modifies the gaussians by updating their mus and sigmas.

    Args:
        gaussians (list): A list of NormPDF objects.
        data (array): The data vector.
        expectation (array): The expectation values for data element
            (as computed by expectation_step()).

    Returns:
        A numpy array of absolute changes in any mu or sigma,
        that means the returned array has twice as many elements as
        the supplied list of gaussians.
    """
    changes = []
    for i in range(len(gaussians)):
        new_mu = weighted_mean(data, expectation[:,i])
        new_sigma = std_dev(new_mu, data, expectation[:,i])
        gaussians[i].alpha = sum(expectation[:,i])/len(data)

        changes.append(fabs(gaussians[i].mu - new_mu))
        changes.append(fabs(gaussians[i].sigma - new_sigma))
        gaussians[i].mu = new_mu
        gaussians[i].sigma = new_sigma

    return np.array(changes)
```

**5) Perform the complete EM and plot your results:**

Initialize three normal distributions whose parameters will be changed iteratively by the EM to converge close to the original distributions.

Build a loop around the iterative procedure of expectation and maximization which stops when the changes in all $\mu_j$ and $\sigma_j$ are sufficiently small enough.

Plot your results after each step and mark which data points belong to which normal distribution. If you don't get it to work, just plot your final solution.

*Hint:* Remember to load the data and initialize the EM before the loop.

*Hint:* A function `plot_intermediate_result` to plot your result after each step is already defined in the next cell. Take a look at what arguments it takes and try to use it in your loop.

*Hint:* To plot your final result the first three images and corresponding code examples on the tutorial of [`plt.plot(...)`](http://matplotlib.org/users/pyplot_tutorial.html) should help you.

*Optional:* Run the code multiple times. If your results are changing, use `np.random.seed(2)` in the beginning of the cell to get consistent results (any other integer will work as well, but 2 has some good results for the example solutions).

```python
%matplotlib notebook
import time
import itertools

import numpy as np
import matplotlib.pyplot as plt
# Sets the random seed to a fix value to make results consistent
# np.random.seed(2)

colors = itertools.cycle(['r', 'g', 'b', 'c', 'm', 'y', 'k'])
figure, axis = plt.subplots(1)
axis.set_xlim(-5, 5)
axis.set_ylim(-0.2, 4)
axis.set_title('Intermediate Results')
final_figure = plt.figure('Final Result')

def plot_intermediate_result(gaussians, data, mapping):
    """
    Gets a list of gaussians and data input. The mapping
    parameter is a list of indices of gaussians. Each value
    corresponds to the data value at the same position and
    maps this data value to the proper gaussian.
    """
    x = np.linspace(-5, 5, 100)
    if len(axis.lines):
        for j, N in enumerate(gaussians):
            axis.lines[j * 2].set_xdata(x)
            axis.lines[j * 2].set_ydata(N(x))
            axis.lines[j * 2 + 1].set_xdata(data[mapping == j])
            axis.lines[j * 2 + 1].set_ydata([0] * len(data[mapping == j]))
    else:
        for j, N in enumerate(gaussians):
            axis.plot(x, N(x), data[mapping == j], [0] * len(data[mapping == j]), 'x', color=next(colors), markersize=5)
    figure.canvas.draw()
    time.sleep(1.0)

def plot_final(gaussians, data, mapping):
    x = np.linspace(-5, 5, 100)
    for j, g in enumerate(gaussians):
        plt.plot(x, g(x), data[mapping == j], [0] * len(data[mapping == j]), 'x', color=next(colors), markersize=5)
    final_figure.canvas.draw()

# Perform the initialization.
data = load_data('em_normdistdata.txt')
gaussians = initialize_EM(data, 3)

# Loop until the changes are small enough.
eps = 0.05
changes = [float('inf')] * 2
expectation = []
while max(changes) > eps:
    # E-step
    expectation = expectation_step(gaussians, data)
    # M-step
    changes = maximization_step(gaussians, data, expectation)
    # Optional: Calculate the parameters to update the plot and call the function to do it.
    plot_intermediate_result(gaussians, data, np.argmax(expectation, 1))

# Plot your final result and print the final parameters.
plot_final(gaussians, data, np.argmax(expectation, 1))
```

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

### b) EM and missing values

Describe in your own words: How does the EM-algorithm deal with the missing value problem?

In order to deal with missing values, the EM-algorithm estimates the distribution underlying the data and then generates numbers that are sampled from that distribution to fill up the missing values.

To find the distribution, or rather the correct parameters of that distribution, the algorithm makes an initial guess and then, in each iteration, the probability for each data point to belong to a distribution is calculated. That result is used to refactor each distribution's parameters (in our case $\mu$ and $\sigma$) by recalculating them with weighted samples, the weight being that samples' probability of belonging to that distribution.


# Introduction

Classical mechanics is a topic which has been taught intensively over
several centuries. It is, with its many variants and ways of
presenting the educational material, normally the first **real** physics
course many of us meet and it lays the foundation for further physics
studies. Many of the equations and ways of reasoning about the
underlying laws of motion and pertinent forces, shape our approaches and understanding
of the scientific method and discourse, as well as the way we develop our insights
and deeper understanding about physical systems.

There is a wealth of
well-tested (from both a physics point of view and a pedagogical
standpoint) exercises and problems which can be solved
analytically. However, many of these problems represent idealized and
less realistic situations.  The large majority of these problems are
solved by paper and pencil and are traditionally aimed
at what we normally refer to as continuous models from which we may find an analytical solution.  As a consequence,
when teaching mechanics, it implies that we can seldomly venture beyond an idealized case
in order to develop our understandings and insights about the
underlying forces and laws of motion.

On the other hand, numerical algorithms call for approximate discrete
models and much of the development of methods for continuous models
are nowadays being replaced by methods for discrete models in science and
industry, simply because **much larger classes of problems can be addressed** with discrete models, often  by simpler and more
generic methodologies.

As we will see below, when properly scaling the equations at hand,
discrete models open up for more advanced abstractions and the possibility to
study  real life systems, with the added bonus that we can explore and
deepen our basic understanding of various physical systems

Analytical solutions are as important as before. In addition, such
solutions provide us with invaluable benchmarks and tests for our
discrete models. Such benchmarks, as we will see below, allow us
to discuss possible sources of errors and their behaviors.  And
finally, since most of our models are based on various algorithms from
numerical mathematics, we have a unique oppotunity to gain a deeper
understanding of the mathematical approaches we are using.

With computing and data science as important elements in essentially
all aspects of a modern society, we could  then try to define Computing as
**solving scientific problems using all possible tools, including
symbolic computing, computers and numerical algorithms, and analytical
paper and pencil solutions**.
Computing provides us with the tools to develope our own understanding of the scientific method by enhancing algorithmic thinking.

The way we will teach this course reflects
this definition of computing. The course contains both classical paper
and pencil exercises as well as  computational projects and exercises. The
hope is that this will allow you to explore the physics of systems
governed by the degrees of freedom of classical mechanics at a deeper
level, and that these insights about the scientific method will help
you to develop a better understanding of how the underlying forces and
equations of motion and how they impact a given system. Furthermore, by introducing various numerical methods
via computational projects and exercises, we aim at developing your competences and skills about these topics.

These competences will enable you to

* understand how algorithms are used to solve mathematical problems,

* derive, verify, and implement algorithms,

* understand what can go wrong with algorithms,

* use these algorithms to construct reproducible scientific outcomes and to engage in science in ethical ways, and

* think algorithmically for the purposes of gaining deeper insights about scientific problems.

All these elements are central for maturing and gaining a better understanding of the modern scientific process *per se*.

The power of the scientific method lies in identifying a given problem
as a special case of an abstract class of problems, identifying
general solution methods for this class of problems, and applying a
general method to the specific problem (applying means, in the case of
computing, calculations by pen and paper, symbolic computing, or
numerical computing by ready-made and/or self-written software). This
generic view on problems and methods is particularly important for
understanding how to apply available, generic software to solve a
particular problem.

*However, verification of algorithms and understanding their limitations requires much of the classical knowledge about continuous models.*

## A well-known examples to illustrate many of the above concepts

Before we venture into a reminder on Python and mechanics relevant applications, let us briefly outline some of the
abovementioned topics using an example many of you may have seen before in for example CMSE201.
A simple algorithm for integration is the Trapezoidal rule.
Integration of a function $f(x)$ by the Trapezoidal Rule is given by following algorithm for an interval $x \in [a,b]$

$$
\int_a^b(f(x) dx = \frac{1}{2}\left [f(a)+2f(a+h)+\dots+2f(b-h)+f(b)\right] +O(h^2),
$$

where $h$ is the so-called stepsize defined by the number of integration points $N$ as $h=(b-a)/(n)$.
Python offers an  extremely versatile programming  environment, allowing for
the inclusion of analytical studies in a numerical program. Here we show an
example code with the **trapezoidal rule**. We use also **SymPy** to evaluate the exact value of the integral and compute the absolute error
with respect to the numerically evaluated one of the integral
$\int_0^1 dx x^2 = 1/3$.
The following code for  the trapezoidal rule allows you  to plot the relative error by comparing with the exact result. By increasing to $10^8$ points one arrives at a region where numerical errors start to accumulate.

```python
%matplotlib inline

from math import log10
import numpy as np
from sympy import Symbol, integrate
import matplotlib.pyplot as plt
# function for the trapezoidal rule
def Trapez(a,b,f,n):
   h = (b-a)/float(n)
   s = 0
   x = a
   for i in range(1,n,1):
       x = x+h
       s = s+ f(x)
   s = 0.5*(f(a)+f(b)) +s
   return h*s
#  function to compute pi
def function(x):
    return x*x
# define integration limits
a = 0.0;  b = 1.0;
# find result from sympy
# define x as a symbol to be used by sympy
x = Symbol('x')
exact = integrate(function(x), (x, a, b))
# set up the arrays for plotting the relative error
n = np.zeros(9); y = np.zeros(9);
# find the relative error as function of integration points
for i in range(1, 8, 1):
    npts = 10**i
    result = Trapez(a,b,function,npts)
    RelativeError = abs((exact-result)/exact)
    n[i] = log10(npts); y[i] = log10(RelativeError);
plt.plot(n,y, 'ro')
plt.xlabel('n')
plt.ylabel('Relative error')
plt.show()
```

This example shows the potential of combining numerical algorithms with symbolic calculations, allowing us to

* Validate and verify  their  algorithms.

* Including concepts like unit testing, one has the possibility to test and test several or all parts of the code.

* Validation and verification are then included *naturally* and one can develop a better attitude to what is meant with an ethically sound scientific approach.

* The above example allows the student to also test the mathematical error of the algorithm for the trapezoidal rule by changing the number of integration points. The students get **trained from day one to think error analysis**.

* With a Jupyter notebook you can keep exploring similar examples and turn them in as your own notebooks.

In this process we can easily bake in
1. How to structure a code in terms of functions

2. How to make a module

3. How to read input data flexibly from the command line

4. How to create graphical/web user interfaces

5. How to write unit tests (test functions or doctests)

6. How to refactor code in terms of classes (instead of functions only)

7. How to conduct and automate large-scale numerical experiments

8. How to write scientific reports in various formats (LaTeX, HTML)

The conventions and techniques outlined here will save you a lot of time when you incrementally extend software over time from simpler to more complicated problems. In particular, you will benefit from many good habits:
1. New code is added in a modular fashion to a library (modules)

2. Programs are run through convenient user interfaces

3. It takes one quick command to let all your code undergo heavy testing

4. Tedious manual work with running programs is automated,

5. Your scientific investigations are reproducible, scientific reports with top quality typesetting are produced both for paper and electronic devices.


```python
# Erasmus+ ICCT project (2018-1-SI01-KA203-047081)

# Toggle cell visibility

from IPython.display import HTML
tag = HTML('''
Promijeni vidljivost <a href="javascript:code_toggle()">ovdje</a>.''')
display(tag)

# Hide the code completely

# from IPython.display import HTML
# tag = HTML('''<style>
# div.input {
#     display:none;
# }
# </style>''')
# display(tag)

```

Promijeni vidljivost ovdje.

## Unutarnja stabilnost

Koncept stabilnosti bilježi ponašanje evolucije stanja sustava kada je isti "izbačen" iz ravnotežnog stanja: stabilnost opisuje divergira li evolucija stanja koja nastaje nakon poremećaja iz točke ravnoteže ili ne.

### Definicija
S obzirom na vremenski nepromjenjiv dinamički sustav opisan vektorom stanja $x(t)\in \mathbb{R}^n$, točkom ravnoteže $x_e$, početnim stanjem $x_0$ i početnim vremenom $t_0$, ako vrijedi

$$
\forall \, \epsilon \in \mathbb{R}, \, \epsilon > 0 \quad \exists \delta \in \mathbb{R}, \, \delta > 0 : \quad ||x_0-x_e|| < \delta \, \Rightarrow  \, ||x(t)-x_e|| < \epsilon \quad \forall t \ge t_0
$$
to bi se moglo protumačiti kao: ako postoji dovoljno mala početna perturbacija $\delta$ od točke ravnoteže takva da evolucija stanja $x(t)$ od točke poremećaja ne odlazi predaleko (više od $\epsilon$) od same ravnoteže, tada je točka ravnoteže stabilna.

Ako se također dogodi $\lim_{t\to\infty}||x(t)-x_e|| = 0$, što se može protumačiti kao: evolucija stanja se vraća natrag u točku ravnoteže, tada se kaže da je ravnoteža asimptotski stabilna.

U slučaju linearnih vremenski nepromjenjivih sustava:

\begin{cases}
\dot{x} = Ax +Bu \\
y = Cx + Du,
\end{cases}

moguće je dokazati da stabilnost jedne točke ravnoteže podrazumijeva stabilnost svih točaka ravnoteže, pa možemo govoriti o stabilnosti sustava čak i ako je, općenito, svojstvo stabilnosti povezano s točkom ravnoteže. Posebnost ovog linearnog sustava posljedica je činjenice da je evolucija ove vrste sustava strogo povezana sa svojstvenim vrijednostima matrice dinamike $A$, koje su invarijantne s obzirom na rotaciju, translaciju, početne uvjete i vrijeme.

Podsjetite se što je objašnjeno u primjeru o modalnoj analizi:

> Rješenje diferencijalne jednadžbe (u zatvorenoj formi), od početnog vremena $t_0$, s početnim uvjetima $x(t_0)$, je
$$
x(t) = e^{A(t-t_0)}x(t_0).
$$ Matrica $e^{A(t-t_0)}x(t_0)$ se sastoji od linearnih kombinacija funkcije vremena $t$, svake tipa: $$e^{\lambda t},$$ gdje su $\lambda$-e svojstvene vrijednosti matrice $A$; ove su funckije modovi sustava.

stoga:
- linearni dinamički sustav stabilan je ako i samo ako svi njegovi modovi nisu divergentni,
- linearni dinamički sustav je asimptotski stabilan ako i samo ako su svi njegovi modovi konvergentni,
- linearni dinamički sustav je nestabilan ako ima barem jedan divergentni mod.

i, s obzirom na svojstvene vrijednosti matrice dinamike, to se događa ako:
- sve svojstvene vrijednosti matrice $A$ pripadaju zatvorenoj lijevoj polovici kompleksne ravnine (tj. realna vrijednost im je negativna ili nula), a, u slučaju da imaju realnu vrijednost nula, njihova je algebarska višestrukost ista kao geometrijska višestrukost (množnost), ili, ekvivalentno tome, imaju skalarne blokove u Jordanovom obliku;
- sve svojstvene vrijednosti pripadaju otvorenoj lijevoj polovici imaginarne ravnine, odnosno imaju strogo negativne realne dijelove;
- barem jedna svojstvena vrijednost ima pozitivan realni dio ili postoje svojstvene vrijednosti s realnom vrijednošću 0 i neskalarni Jordanovi blokovi.

Ovaj interaktivni primjer predstavlja matricu dinamike $A$ koju je moguće uređivati, a prikazuje slobodni odziv sustava uz odgovarajuće svojstvene vrijednosti.

### Kako koristiti ovaj interaktivni primjer?
- Pokušajte promijeniti svojstvene vrijednosti i početni uvjet $x_0$ i pogledajte kako se mijenja odziv.

```python
%matplotlib inline
#%matplotlib notebook
import control as control
import numpy
import sympy as sym
from IPython.display import display, Markdown
import ipywidgets as widgets
import matplotlib.pyplot as plt

#print a matrix latex-like
def bmatrix(a):
     """Returns a LaTeX bmatrix - by Damir Arbula (ICCT project)

     :a: numpy array
     :returns: LaTeX bmatrix as a string
     """
     if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
     lines = str(a).replace('[', '').replace(']', '').splitlines()
     rv = [r'\begin{bmatrix}']
     rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
     rv +=  [r'\end{bmatrix}']
     return '\n'.join(rv)

# Display formatted matrix:
def vmatrix(a):
    if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
    lines = str(a).replace('[', '').replace(']', '').splitlines()
    rv = [r'\begin{vmatrix}']
    rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
    rv +=  [r'\end{vmatrix}']
    return '\n'.join(rv)

#matrixWidget is a matrix looking widget built with a VBox of HBox(es) that returns a numPy array as value !
class matrixWidget(widgets.VBox):
    def updateM(self,change):
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.M_[irow,icol] = self.children[irow].children[icol].value
                #print(self.M_[irow,icol])
        self.value = self.M_

    def dummychangecallback(self,change):
        pass

    def __init__(self,n,m):
        self.n = n
        self.m = m
        self.M_ = numpy.matrix(numpy.zeros((self.n,self.m)))
        self.value = self.M_
        widgets.VBox.__init__(self,
                             children = [
                                 widgets.HBox(children =
                                              [widgets.FloatText(value=0.0, layout=widgets.Layout(width='90px')) for i in range(m)]
                                             )
                                 for j in range(n)
                             ])

        #fill in widgets and tell interact to call updateM each time a children changes value
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
                self.children[irow].children[icol].observe(self.updateM, names='value')
        #value = Unicode('example@example.com', help="The email value.").tag(sync=True)
        self.observe(self.updateM, names='value', type= 'All')

    def setM(self, newM):
        #disable callbacks, change values, and reenable
        self.unobserve(self.updateM, names='value', type= 'All')
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].unobserve(self.updateM, names='value')
        self.M_ = newM
        self.value = self.M_
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].observe(self.updateM, names='value')
        self.observe(self.updateM, names='value', type= 'All')

                #self.children[irow].children[icol].observe(self.updateM, names='value')

#overlaod class for state space systems that DO NOT remove "useless" states (what "professor" of automatic control would do this?)
class sss(control.StateSpace):
    def __init__(self,*args):
        #call base class init constructor
        control.StateSpace.__init__(self,*args)
    #disable function below in base class
    def _remove_useless_states(self):
        pass
```

```python
# Preparatory cell

A = numpy.matrix([[0,1],[-2/5,-1/5]])
X0 = numpy.matrix('5; 3')

Aw = matrixWidget(2,2)
Aw.setM(A)
X0w = matrixWidget(2,1)
X0w.setM(X0)
```

```python
# Misc

#create dummy widget
DW = widgets.FloatText(layout=widgets.Layout(width='0px', height='0px'))

#create button widget
START = widgets.Button(
    description='Test',
    disabled=False,
    button_style='', # 'success', 'info', 'warning', 'danger' or ''
    tooltip='Test',
    icon='check'
)

def on_start_button_clicked(b):
    #This is a workaround to have intreactive_output call the callback:
    #   force the value of the dummy widget to change
    if DW.value> 0 :
        DW.value = -1
    else:
        DW.value = 1
    pass
START.on_click(on_start_button_clicked)
```

```python
# Main cell

def main_callback(A, X0, DW):
    sols = numpy.linalg.eig(A)
    sys = sss(A,[[1],[0]],[0,1],0)
    pole = control.pole(sys)
    if numpy.real(pole[0]) != 0:
        p1r = abs(numpy.real(pole[0]))
    else:
        p1r = 1
    if numpy.real(pole[1]) != 0:
        p2r = abs(numpy.real(pole[1]))
    else:
        p2r = 1
    if numpy.imag(pole[0]) != 0:
        p1i = abs(numpy.imag(pole[0]))
    else:
        p1i = 1
    if numpy.imag(pole[1]) != 0:
        p2i = abs(numpy.imag(pole[1]))
    else:
        p2i = 1

    print('Svojstvene vrijednosti matrice A su:',round(sols[0][0],4),'i',round(sols[0][1],4))

    #T = numpy.linspace(0, 60, 1000)
    T, yout, xout = control.initial_response(sys,X0=X0,return_x=True)

    fig = plt.figure("Svojstvene vrijednosti od A", figsize=(16,16))
    ax = fig.add_subplot(311,title='Polovi (Re vs Img)')
    #plt.axis(True)
    # Move left y-axis and bottim x-axis to centre, passing through (0,0)
    # Eliminate upper and right axes
    ax.spines['left'].set_position(('data',0.0))
    ax.spines['bottom'].set_position(('data',0.0))
    ax.spines['right'].set_color('none')
    ax.spines['top'].set_color('none')
    ax.set_xlim(-max([p1r+p1r/3,p2r+p2r/3]),
                max([p1r+p1r/3,p2r+p2r/3]))
    ax.set_ylim(-max([p1i+p1i/3,p2i+p2i/3]),
                max([p1i+p1i/3,p2i+p2i/3]))

    plt.plot([numpy.real(pole[0]),numpy.real(pole[1])],[numpy.imag(pole[0]),numpy.imag(pole[1])],'o')
    plt.grid()

    ax1 = fig.add_subplot(312,title='Slobodni odziv')
    plt.plot(T,xout[0])
    plt.grid()
    ax1.set_xlabel('vrijeme [s]')
    ax1.set_ylabel('$x_1$')
    ax1.axvline(x=0,color='black',linewidth='0.8')
    ax1.axhline(y=0,color='black',linewidth='0.8')
    ax2 = fig.add_subplot(313)
    plt.plot(T,xout[1])
    plt.grid()
    ax2.set_xlabel('vrijeme [s]')
    ax2.set_ylabel('$x_2$')
    ax2.axvline(x=0,color='black',linewidth='0.8')
    ax2.axhline(y=0,color='black',linewidth='0.8')

    #plt.show()

alltogether = widgets.HBox([widgets.VBox([widgets.Label('$A$:',border=3),
                                          Aw]),
                                          widgets.Label('   ',border=3),
                            widgets.VBox([widgets.Label('$X_0$:',border=3),
                                          X0w]),
                                          START])
out = widgets.interactive_output(main_callback, {'A':Aw, 'X0':X0w, 'DW':DW})
out.layout.height = '1000px'
display(out, alltogether)
```

    Output(layout=Layout(height='1000px'))

    HBox(children=(VBox(children=(Label(value='$A$:'), matrixWidget(children=(HBox(children=(FloatText(value=0.0, …


```python
# Erasmus+ ICCT project (2018-1-SI01-KA203-047081)

# Toggle cell visibility

from IPython.display import HTML
tag = HTML('''
Promijeni vidljivost <a href="javascript:code_toggle()">ovdje</a>.''')
display(tag)

# Hide the code completely

# from IPython.display import HTML
# tag = HTML('''<style>
# div.input {
#     display:none;
# }
# </style>''')
# display(tag)

```

Promijeni vidljivost ovdje.

## Beskonačne točke ravnoteže 3

Ovaj interaktivni primjer prikazuje $2\times2$ sustav koji ima beskonačno mnogo točaka ravnoteže koje leže na pravcu $x_1=-x_2$ (za podležeću teoriju pogledajte interaktivnu lekciju o točkama ravnoteže).

Da bi pravac $x_1=-x_2$ bio prostor ravnotežnih točaka, treba vrijediti:
$$
A\bar{x}=0 \quad \forall \, \bar{x}\in\begin{bmatrix} \alpha \\ -\alpha\end{bmatrix} \, \text{uz} \, \alpha\in\mathbb{R},
$$
stoga, $\begin{bmatrix} \alpha \\ -\alpha\end{bmatrix}$ mora pripadati "null-prostoru" (engl. nullspace) matrice $A$.

### Kako koristiti ovaj interaktivni primjer?
- Izravno izmijenite matricu $A$ da biste vidjeli kako se mijenjaju točke ravnoteže.
- Pokušajte modificirati $A$ na način da točke ravnoteže leže na zadanom skupu.

```python
#Preparatory Cell

import control
import numpy
from IPython.display import display, Markdown
import ipywidgets as widgets
import matplotlib.pyplot as plt
import sympy as sym

#print a matrix latex-like
def bmatrix(a):
     """Returns a LaTeX bmatrix - by Damir Arbula (ICCT project)

     :a: numpy array
     :returns: LaTeX bmatrix as a string
     """
     if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
     lines = str(a).replace('[', '').replace(']', '').splitlines()
     rv = [r'\begin{bmatrix}']
     rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
     rv +=  [r'\end{bmatrix}']
     return '\n'.join(rv)

# Display formatted matrix:
def vmatrix(a):
    if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
    lines = str(a).replace('[', '').replace(']', '').splitlines()
    rv = [r'\begin{vmatrix}']
    rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
    rv +=  [r'\end{vmatrix}']
    return '\n'.join(rv)

#create a NxM matrix widget
def createMatrixWidget(n,m):
    M = widgets.GridBox(children=[widgets.FloatText(layout=widgets.Layout(width='100px', height='40px'),
    value=0.0, disabled=False, label=i) for i in range(n*m)],
    layout=widgets.Layout(
        #width='50%',
        grid_template_columns= ''.join(['100px ' for i in range(m)]),
        #grid_template_rows='80px 80px 80px',
        grid_row_gap='0px',
        track_size='0px')
    )
    return M

#extract matrix from widgets and convert to numpy matrix
def getNumpyMatFromWidget(M,n,m):
    #get W gridbox dims
    M_ = numpy.matrix(numpy.zeros((n,m)))
    for irow in range(0,n):
        for icol in range(0,m):
            M_[irow,icol] = M.children[irow*3+icol].value

#this is a simple derived class from FloatText used to experience with interact
class floatWidget(widgets.FloatText):
    def __init__(self,**kwargs):
        #self.n = n
        self.value = 30.0
        #self.M =
        widgets.FloatText.__init__(self, **kwargs)

#    def value(self):
#        return 0 #self.FloatText.value

from traitlets import Unicode
from ipywidgets import register

#matrixWidget is a matrix looking widget built with a VBox of HBox(es) that returns a numPy array as value !
class matrixWidget(widgets.VBox):
    def updateM(self,change):
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.M_[irow,icol] = self.children[irow].children[icol].value
                #print(self.M_[irow,icol])
        self.value = self.M_

    def dummychangecallback(self,change):
        pass

    def __init__(self,n,m):
        self.n = n
        self.m = m
        self.M_ = numpy.matrix(numpy.zeros((self.n,self.m)))
        self.value = self.M_
        widgets.VBox.__init__(self,
                             children = [
                                 widgets.HBox(children =
                                              [widgets.FloatText(value=0.0, layout=widgets.Layout(width='90px')) for i in range(m)]
                                             )
                                 for j in range(n)
                             ])

        #fill in widgets and tell interact to call updateM each time a children changes value
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
                self.children[irow].children[icol].observe(self.updateM, names='value')
        #value = Unicode('example@example.com', help="The email value.").tag(sync=True)
        self.observe(self.updateM, names='value', type= 'All')

    def setM(self, newM):
        #disable callbacks, change values, and reenable
        self.unobserve(self.updateM, names='value', type= 'All')
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].unobserve(self.updateM, names='value')
        self.M_ = newM
        self.value = self.M_
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].observe(self.updateM, names='value')
        self.observe(self.updateM, names='value', type= 'All')

                #self.children[irow].children[icol].observe(self.updateM, names='value')

#overlaod class for state space systems that DO NOT remove "useless" states (what "professor" of automatic control would do this?)
class sss(control.StateSpace):
    def __init__(self,*args):
        #call base class init constructor
        control.StateSpace.__init__(self,*args)
    #disable function below in base class
    def _remove_useless_states(self):
        pass
```

```python
#define the matrixes
A=matrixWidget(2,2)
A.setM(numpy.matrix('1. 0.; 0. 1.'))

def main_callback(matA,DW):

    As = sym.Matrix(matA)
    NAs = As.nullspace()

    t = numpy.linspace(-10,10,1000)
    if len(NAs) == 1:
        eq1 = [t[i]*numpy.matrix(NAs[0]) for i in range(0,len(t))]
        x1 = [eq1[i][0,0] for i in range(0,len(t))]
        x2 = [eq1[i][1,0] for i in range(0,len(t))]

    fig = plt.figure(figsize=(6,6))
    if len(NAs) == 0:
        plt.plot(0,0,'bo')
    if len(NAs) == 1:
        plt.plot(x1,x2)
    if len(NAs) == 2:
        plt.fill((-5,-5,5,5),(-5,5,5,-5),alpha=0.5)
        plt.xlim(left=-5,right=5)
        plt.ylim(top=5,bottom=-5)
    plt.grid()
    plt.xlabel('$x_1$')
    plt.ylabel('$x_2$')
    print('Osnova za A-nullspace (po retcima) je %s. \nSvojstvene vrijednosti su %s' %(str(numpy.array(NAs)),
                                                                          str(numpy.linalg.eig(matA)[0])))

#create dummy widget
DW = widgets.FloatText(layout=widgets.Layout(width='0px', height='0px'))

#create button widget
START = widgets.Button(
    description='Test',
    disabled=False,
    button_style='', # 'success', 'info', 'warning', 'danger' or ''
    tooltip='Test',
    icon='check'
)

def on_start_button_clicked(b):
    #This is a workaround to have intreactive_output call the callback:
    #   force the value of the dummy widget to change
    if DW.value> 0 :
        DW.value = -1
    else:
        DW.value = 1
    pass
START.on_click(on_start_button_clicked)

out = widgets.interactive_output(main_callback,{'matA':A,'DW':DW})
out1 = widgets.HBox([out,
                     widgets.VBox([widgets.Label(''),widgets.Label(''),widgets.Label(''),widgets.Label('$\qquad \qquad A=$')]),
                     widgets.VBox([widgets.Label(''),widgets.Label(''),widgets.Label(''),A,START])])
out.layout.height = '450px'
display(out1)
```

    HBox(children=(Output(layout=Layout(height='450px')), VBox(children=(Label(value=''), Label(value=''), Label(v…

```python
#create dummy widget 2
DW2 = widgets.FloatText(layout=widgets.Layout(width='0px', height='0px'))
DW2.value = -1

#create button widget
START2 = widgets.Button(
    description='Prikaži odgovore',
    disabled=False,
    button_style='', # 'success', 'info', 'warning', 'danger' or ''
    tooltip='Klikni za prikaz odgovora',
    icon='check'
)

def on_start_button_clicked2(b):
    #This is a workaround to have intreactive_output call the callback:
    #   force the value of the dummy widget to change
    if DW2.value> 0 :
        DW2.value = -1
    else:
        DW2.value = 1
    pass
START2.on_click(on_start_button_clicked2)

def main_callback2(DW2):
    if DW2 > 0:
        display(Markdown(r'''Odgovor:
Za konstrukciju matrice moguće je odabrati vektore redaka koji su ortogonalni u odnosu na nullspace. Moguća matrica je stoga:

$$
A=\begin{bmatrix} 1 & 1 \\ 2 & 2 \end{bmatrix}.
$$'''))
    else:
        display(Markdown(''))

#create a graphic structure to hold all widgets
alltogether2 =  widgets.VBox([START2])

out2 = widgets.interactive_output(main_callback2,{'DW2':DW2})
#out.layout.height = '300px'
display(out2,alltogether2)
```

    Output()

    VBox(children=(Button(description='Prikaži odgovore', icon='check', style=ButtonStyle(), tooltip='Klikni za pr…


# Half-Life and Radioactive Decay

The half-life of an unstable atom is the amount of time required on average for half of a population of that atom to decay to a different element. The half-life can range from yottoseconds ($10^{24}$ seconds!) to numbers so large that it's hard to consider the atom unstable (for example Tellerium-128 has a half-life of approximately $10^{24}$ (yotta) years). A short list of select atomic half-lives is available from Wikipedia [here](https://en.wikipedia.org/wiki/List_of_radioactive_isotopes_by_half-life).

As an example, suppose you have a collection of one thousand atoms which have a half-life of twenty minutes. After twenty minutes your collection of those atoms will have shrunk to approximately five hundred atoms. However, that does not mean that your atoms have disappeared. Those unstable atoms have **decayed**, meaning that the atom has lost energy by emitting radiation and changed either its number of neutrons, protons, or both! Most commonly atoms decay by emitting a electrons, neutrons, positrons, or alpha particles (which are helium nuclei, He$^{2+}$). What happens in each of these decay processes is outlined below.

## Modes of decay

### Alpha ($\alpha$) Decay

Alpha decay is a decay process in which an atom releases an $\alpha$ particle. An $\alpha$ particle is simply another name for an ionized (no electrons) helium atom $\left(^4_2He\right)$, so if an element undergoes alpha decay, the atom reduces its number of neutrons $N$, and the number of protons $Z$. In general, for an atom X with atomic number $A$, the sum of neutrons and protons, decaying into an atom $Y$ via $\alpha$ decay has the following form:

\begin{equation}
^A_ZX^N \rightarrow ^{A-4}_{Z-2}Y^{N-2} + ^4_2\alpha^2
\end{equation}

Here $A$ is the **atomic number** of an atom, or the sum of its number of neutrons $N$ and protons $Z$.

\begin{equation}
A = Z + N
\end{equation}

Alpha decay, written in terms of a concrete example, the $\alpha$ decay of Uranium-235 would appear as

\begin{equation}
_{92} ^{235} \text{U}^{143} \rightarrow _{90} ^{231}\text{Th}^{141} + _2 ^4\alpha^{2}
\end{equation}

Where we see that uranium-235 decays to Thorium-231 by emitting an $\alpha$ particle, it alpha decays. Notice however, that the total number of neutrons and protons are equal on both the right hand side and the left hand side of the reaction. Generally speaking however, the number of neutrons is not typically written explicitly, and calculated by subtracting $Z$ from $A$.

### Beta $\beta$ Decay

$\beta$ decay comes in two flavors: beta positive ($\beta^+$) decay and beta minus ($\beta^-$) decay. $\beta^-$ decay emits an electron, and $\beta^+$ decay emits the anti-particle of the electron: the positron (a particle with the same mass, but opposite charge of an electron). In the case of $\beta^+$ decay, the element emits a positron from a proton, therefore the atom 'loses' a proton and 'gains' a neutron.  In the case of $\beta^-$ decay, a neutron emits an electron meaning the atom 'loses' a neutron and 'gains' a proton.

They also emit mysterious and common particle known as a neutrino

## The Neutrino

### Beta Minus decay

Generally speaking $\beta^-$ decay has the following form

\begin{equation}
^A _Z\text{X}^N \rightarrow _{Z+1}^A\text{Y}^{N-1} + e^- + \bar{\nu}
\end{equation}

where $\bar{\nu}$ is an anti-neutrino. Notice that charge and mass is conserved. From the above formula, we see that a neutron is emitting an electron and anti-neutrino and then becomes a proton. Notice how both charge and mass is conserved, we gained a positive charge with the proton, but also gained a negative charge in the process with an electron.

### Beta plus decay

\begin{equation}
^A _Z\text{X}^{N} \rightarrow _{Z-1}^A\text{Y}^{N+1} + e^+ + \nu
\end{equation}

where $\nu$ is a neutrino. Notice that charge and mass is again conserved. From the above formula, we see that a proton is emitting a positron and neutrino, and the becomes a neutron.

### Electron Capture
This wasn't in the Alberta curriculum thing but would it important? There's also a whole bunch of others that we could toss in here that could be considered optional. I kind of want to put it in because then the neutron capture stuff will make more sense. Not that that's required either, but it would make for a cool animation.

## Some Mathematical Background
Let's build on our earlier discussion and start introducing some mathematical rigor to our discussion of half life. The half-life of an atom is denoted as $\tau_{1/2}$, measured in seconds,  where $\tau$ is the Greek letter tau. Using the half life of an atom, we can calculate the number of atoms as a function of time $N(t)$ with the following relationship

\begin{equation}
N(t) = N_0 \; 2.718^{-\lambda t}
\label{eq:decay}
\tag{1}
\end{equation}

Where $N(t)$ is the _number_ of atoms you have at time $t$, $N_0$ is the original number of atoms at $t=0$ and $\lambda$ is known as the "half life constant" which has units of inverse seconds. The half life constant is a bit of an abstraction from the more intuitive half life, however $\lambda$ is related to the half life by the following simple relationship

\begin{equation}
\lambda = \frac{0.693}{\tau_{1/2}}
\label{eq:lambda}
\tag{2}
\end{equation}

The above two equations make it is possible to calculate the amount of atoms remaining at any given time, provided   the half life and the original number of atoms is known. Another, more subtle, fact about equation \ref{eq:decay} that is worth pointing out is that the exponential part ($2.718^{\lambda t}$) of that equation is directly related to the _probability_ that an atom will decay at a given time. Intuitively this makes sense, which we illustrate with an example. Suppose we started with one thousand atoms, and after ten seconds we find that we only have 60% of our atoms remaining. Using equation \ref{eq:decay} we can write this scenario as follows with $N_0 = 1000$

\begin{equation}
N(t= 3 \text{ s}) = 1000 \times 2.718^{-\lambda \; 3 \text{s}} = 600
\tag{3}
\label{eq:probrelat}
\end{equation}

We can manipulate the left hand side of this equation to show

\begin{equation}
N(t = 3 \text{ s}) = 2.718^{-\lambda \; 3 \text{s}} = \frac{600}{1000} = 0.6 = 60 \%
\end{equation}

Where if we substitute our new expression for $N(t = 3 \text{ s})$ back to equation \ref{eq:probrelat}, we see that this function is analogous to a probability. In other words, the expression

\begin{equation}
2.718^{-\lambda t}
\end{equation}

is the probability that an atom **will not** decay at a given time $t$, or

\begin{equation}
P_{exists}(t) = 2.718^{-\lambda t}
\label{eq:exist}
\tag{4}
\end{equation}

Where $P_{exists}(t)$ is the probability that an atom will exist at time $t$.

Now that we have an expression for the probability that an atom will **not** decay, we can now calculate the probability that an atom **does** decay. The probability that an atom **does** decay is simply one minus the probability of it not decaying, or

\begin{equation}
P_{decayed}(t) = 1 - P_{exists}(t) = 1 - 2.718^{-\lambda t}
\label{eq:decayed}
\tag{5}
\end{equation}

Where $P_{decayed}(t)$ is the probability that an atom has decayed by time $t$.

This means that the expression in equation \ref{eq:decay} is a "smooth idealization". Radioactive decay is a statistical process, and actual data will only ever _approach_ the solution of equation \ref{eq:decay}. In reality, our observations of radioactive will randomly fluctuate around that solution. To visualize this, we can use Python to get an idea of what we may measure if we were to observe an actual population of atoms decay.

## Python Example

Using the relationships defined in equations \ref{eq:exist} and \ref{eq:decayed}, it is possible to construct a simple model to visualize how a population of atoms may be observed to decay. This is the case because we know the probability that an atom will decay or not at any given time step. If we know the probability of decay, we simply need to compare this probability to some metric in order to decide if our atom decays or not.

Sounds simple, but that's not an obvious metric to know. How do you decide which atoms decay? Suppose we have an atom that has a 40% probability of decay, how exactly do we decide if this atom decays at a given time or not? Do we simply consider a large group of these atoms and choose 40% of them and say they have decayed? This is certainly an option, however, if we do it this way we won't be observing the interesting random statistical fluctuations, as we would simply be using definition of equation \ref{eq:decay}. What methodology should we use in order to view actual fluctuations?

### Radioactive decay as an analogy to tossing a biased coin

Suppose you were sifting through a bag of atoms and deciding which ones decay by throwing them away according to their half life. Let's suppose that at your current instance in time, each atom has a 50% probability of decay. Doing this manually, conceivably you could pick an atom and flip a coin to decide its fate. If the coin showed heads you could keep the atom (the atom does not decay), and if the coin showed tails you could throw it away (the atom decays). Once you've gone through the entire set of atoms, you will have thrown away those atoms randomly according to their probability of decay. This is a reasonable methodology: you're still removing half of the atoms randomly, and the act of removing one atom is completely independent from the act of removing another. Is there some way to generalize this process and apply it to what we've learned about nuclear decay?

Luckily, Python allows us to draw random numbers which are uniformly distributed between 0 and 1 algorithmically. In other words, there is a Python function which allows us to randomly draw a percentage between 0 and 100%, where each percentage is equally probable to draw. Using this function, we can compare our new random percentage, against our probabilities of decay to decide if our atom decays or not! This is exactly the same as flipping a coin for each atom, except now we're using a "biased coin" which can favor heads over tails (or in our case decaying or not decaying). That's still pretty abstract in terms of what this actually means. For an introduction to simulating a biased coin toss please see [coin flipping](../Math/FlippingCoins.ipynb) for a brief introduction. Of course, if  a deeper understanding of how the simulation of nuclear decay is not of interest to you, the following section is not required and you can feel free to skip the next section.

### Simulating nuclear decay
The way in which we look through our "bag" of atoms can be depicted as a flow chart as seen below
>
> Here we see the flowchart for the process of simulating nuclear decay. Here we see that for every instance in time $t_i$ we have some number of atoms $N$. At that instance in time, we compare the probability of decay for each atom to a unique random number $r$. If the random number is less than our probability of decay, then the atom is said to have decayed. We then remove that atom from the bag. Once we have checked every atom at a particular instance in time, we now move to our next instance in time with our new number of atoms $N$ and repeat the process

The pseudo-code below shows how you would write this process with in code. Here we're looking through our "bag" of atoms, then the comparison between the random number and our calculated probability of decay acts as a (biased) coin toss to decide if the atom decays or not.

```python

for each instance_in_time in time:
    probability_of_decay = 1 - exp(-lambda * instance_in_time)
    for each atom in atoms:
        # This is a random number between 0 and 1
        random_percent = random_number()
        if prob_decay > r:
            atom.decays()
```

Where here `instance_in_time` is an equally spaced time step (steps of one second for example), and we're looping over many time steps. At each time step, we then calculate the probability that an atom decays at that instance in time by equation 5. We then loop over all atoms, generate a random number between 0 and 1 (random percentage between 0 and 100) and use that to decide if an atom decays on an atom to atom basis. Then, we do it again at the next time step. In terms of the coin toss analogy if `probability_of_decay` is greater than `random_percent`, our coin is heads and the atom does not decay. Should `probability_of_decay` be less than `random_percent` then our coin toss is tails and the atom decays.

To illustrate this with an example, if `probability_of_decay` is 76%, and the random number `random_percent` was 45%, our atom would not decay, however, if `random_percent` was greater than 76%, the atom would decay. As the random numbers are _uniformly_ distributed, that means that each percentage between 0 and 100 are all equally probable.

An important thing to notice that we calculate the probability of decay **before** we enter the loop where we see if an atom decays or not. This is because at a single instant of time, the probability of decay for each atom is **independent**: if one atom decays, this does not affect if any of the others also decay.

What to keep in mind here is that the basis behind this simulation is deceptively simple: We're simply deciding **if** each atom decays by flipping a biased coin at each step in time. If you were to do this "by hand" you would quite literally just be flipping coins and counting atoms. And that's precisely what our simulation is doing, just faster than we can.

Below, we see an example of this simulation behavior for carbon-15, which has a half-life of 2.249 seconds.

For each step in time, we need to check if each atom decays or not. We only calculate our probability once because at a single instant in time, the probability of decay is the same for each -- they are independent events.

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

def HalfLifeEquation(N_0, lamb, t):
    return N_0 * 2.71828 ** (-lamb * t)

t = np.linspace(0,4*2.449,500) # Halflife of carbon-15 is 2.249 seconds
plt.figure(figsize=(3, 2), dpi= 175, facecolor='w', edgecolor='k')
plt.plot(t, HalfLifeEquation(1000, np.log(2)/2.249, t), label="Predicted")
plt.ylabel("Number of atoms")
plt.xlabel("t (seconds)")
plt.title("Carbon 15 Decay")

def Decay(N_0, lamb,max_time, steps=100):
    time_atom_pairs = []
    N = N_0
    time_step = max_time/steps
    t = 0
    for i in range(steps):
        # the equation from eariler, divide by N to reuse function
        p = 1 - HalfLifeEquation(N, lamb, time_step)/N
        for i in range(N):
            # a 'random' number between 0 and 1 to compare to our decay probability
            r = random.uniform(0,1)
            if r < p: # if our decay probability for the atom is greater, that atom decays.
                N = N -1
            if N == 0: # if we run out of atoms we should stop
                break
        # We have now moved forward in time once again!
        t = t + time_step
        # Store how many atoms at a given time t we have in an array.
        time_atom_pairs.append([N, t])
    return time_atom_pairs
decays = Decay(1000, np.log(2)/2.249, 10, steps=500)

y,x = np.array(decays).T
plt.plot(x, y, label = "Simulated")
plt.legend()
plt.show()
```

From the plot above we see that the simulated curve (orange) follows the predicted one (blue), but does not follow it exactly. This is a result of atomic decay being a statistical process.

# Simulations

Below you have a widget where you can compare how quickly different atoms decay, and view a simulation of how that process would look if you were to observe these counts yourself. The data is pulled automatically from the [National Institutes of Standards](https://www.nist.gov/pml/radionuclide-half-life-measurements/radionuclide-half-life-measurements-data).

You can choose which isotopes to watch their decay with the element drop-down menus, and you can run for more or less time with the `Time_Scale` slider. Move the slider around at the bottom to advance the decay simulation forward or backwards in time.

## Using the data

```python
url = 'https://www.nist.gov/pml/radionuclide-half-life-measurements/radionuclide-half-life-measurements-data'
df = pd.read_html(url)[0]

# Rename the columns
df.columns = ["Radionuclide",
              "NumberOfSources",
              "HalfLifes_Followed",
              "HalfLife",
              "StandardUncertainty",
              "OtherUncertainty",
              "ref"]
df = df.drop(['ref', 'OtherUncertainty'], axis=1)
df
```

Once the data is tabulated, we can use it in combination with equation 1 we can create the theoretical traces, and use it in combination with equation 5 to generate our "observed" decay path. These are shown graphically with the widget below

Feel free to simulate decay over a longer time frame by adjusting `Time_Scale` and changing the decay isotope using the drop down menus.

```python
from ipywidgets import interactive
import plotly as py
import plotly.graph_objs as go
py.offline.init_notebook_mode()

# Read and calculate the half-life of each element in the data frame.
def get_halflife(Element):
    multiplies = {'s':1., "min":60., "h":3600., "d":3600.*24.}
    index = index1 = np.where(df["Radionuclide"] == Element)[0][0]
    data = df["HalfLife"].iloc[index]
    half_life, pm, d_time, unit = data.split()
    scale = multiplies[unit]
    HalfLife = float(scale) * float(half_life)
    return HalfLife, unit

# calculate and animate the decay of two atoms on the same graph
def DecayRace2(Element1, Element2, Time_Scale=1):
    N_0 = 1000
    Decay_Points = 75
    # Grab the half-lives
    HalfLife_Element_1, unit1 = get_halflife(Element1)
    HalfLife_Element_2, unit2 = get_halflife(Element2)
    time_length = max(HalfLife_Element_1, HalfLife_Element_2)
    # Points for antimation of decay
    Points_Element_1 = Decay(N_0, np.log(2)/(float(HalfLife_Element_1)),
                      max_time= Time_Scale * float(time_length), steps=Decay_Points)
    Points_Element_2 = Decay(N_0, np.log(2)/(float(HalfLife_Element_2)),
                      max_time= Time_Scale * float(time_length), steps=Decay_Points)
    # Make Points
    y1,x1 = np.array(Points_Element_1).T
    y2,x2 = np.array(Points_Element_2).T
    # For the predicted traces
    t = np.linspace(0, Time_Scale * float(time_length), Decay_Points)
    y3 = HalfLifeEquation(N_0, np.log(2)/(float(HalfLife_Element_1)), t)
    y4 = HalfLifeEquation(N_0, np.log(2)/(float(HalfLife_Element_2)), t)
    Decay_Element_1 = go.Scatter(x = x1, y = y1, mode="markers+lines")
    Decay_Element_2 = go.Scatter(x = x2, y = y2, mode="markers+lines")
    Predicted_Element_1 = [dict(type = 'scatter',
            visible = False, # hide until we have selected with slider
            name = Element1, mode = 'lines',
            line = dict(color = 'rgb(116,130,143)'), x = t, y = y3) for step in range(len(y3))]
    # Make the first one visible by default
    Predicted_Element_1[0]['visible'] = True
    # Same as above with different data
    Predicted_Element_2 =  [dict(type='scatter', visible = False, mode = 'lines', name = Element2,
            line = dict(color = 'rgb(150,192,206)'), x = t, y = y4) for step in range(len(y4))]
    Predicted_Element_2[0]['visible'] = True
    data1 = [dict(type='scatter', visible = False, name = " ".join(["Simulated", Element1]),
            marker = dict(color = 'rgb(194,91,86)', line = dict(color = 'rgb(194,91,86)')),
            mode = 'markers+lines', x = x1[0:step], y = y1[0:step]) for step in range(len(x1))]
    data2 = [dict(type='scatter', visible = False, name = " ".join(["Simulated", Element2]),
            mode = 'markers+lines',
            marker = dict(color = 'rgb(190,185,191)', line = dict(color = 'rgb(190,185,191)')),
            x = x2[0:step], y = y2[0:step]) for step in range(len(x2))]
    steps = []
    for i in range(len(data1)):
        step = dict(method = 'restyle', args = ['visible', [False] * len(data1)],)
        step['args'][1][i] = True
        steps.append(step)
    # Add those arrays together for slider plots.
    # As long as they're the same size, this works perfectly
    data = data1 + data2 + Predicted_Element_1 + Predicted_Element_2
        # Create our slider for control of the graph
    sliders = [dict(active = 1, currentvalue = {"prefix": "Time Step: "},
                    pad = {"t":50}, steps = steps)]
        # Update title to display the half-lives of our elements
    hlt = "".join(['Half life of ',Element1,': ','{:0.3e}'.format(HalfLife_Element_1),' ',unit1,
                 '<br>',
                 'Half life of ',Element2,': ','{:0.3e}'.format(HalfLife_Element_2),' ',unit2])
        # Make a nice graph
    layout = dict(sliders=sliders, title = hlt,
                  xaxis = {"title":"Time (seconds)"},
                  yaxis = {"title":"Number of Atoms"})
    fig = py.graph_objs.Figure(data=data, layout=layout)
    py.offline.iplot(fig)
# Surpress warnings from IPython that we don't care about.
import warnings
import sys
if not sys.warnoptions:
    warnings.simplefilter("ignore")
# This gives us the drop down menu functionality
interactive_plot = interactive(DecayRace2, Element1=df["Radionuclide"], Element2 = df["Radionuclide"], continuous_update=True)
interactive_plot
```

## Questions
1. Drag the slider to animate the decay of the same element twice. Do the traces look the same? If so why? If not, why not?
2. Compare two isotopes with very different half-lives, what are the major differences you notice between their simulated and predicted traces?

# Fusion Processes and the Origin of the Heavy Elements

It is well accepted that every star in the observable universe is primarily powered by the fusion of hydrogen into helium. However, stars also gain energy from the fusion of heavier elements up until iron-56 $^{56}Fe$. This is because the amount of energy it takes to combine elements past this point actually [require more energy than they produce](https://en.wikipedia.org/wiki/Nuclear_binding_energy). Typically, when a star begins creating Iron, the star is at the end of its life. However, if stars stop producing the elements at iron 56, how do we get the rest of the periodic table? Well, stellar nucleosynthesis!

## Stellar Nucleosynthesis

[Stellar nucleosynthesis](https://en.wikipedia.org/wiki/Stellar_nucleosynthesis) is the creation of atomic nucleides during high-energy astrophysical events such as [Supernova](https://en.wikipedia.org/wiki/Supernova_nucleosynthesis), [neutron star mergers](https://physics.aps.org/articles/v10/114), or [quark novae](http://www.quarknova.ca/_include/files/feature_quark_stars.pdf). During these high energy events, it is theorized that there are an untold number of free neutrons which can be bound to various nuclei. Essentially an atom moving through a great number of neutrons would pick up many neutrons rapidly creating very heavy, very unstable isotopes.
If a nuclei picks up a free neutron, this process is known as neutron capture, which has the general formula for an atom $X$ and neutron $N$

\begin{equation}
^{A}_ZX^N + N \rightarrow ^{A+1}_Z X^{N+1}
\end{equation}

Where this has formed a different **isotope** of the atom $X$.
In a high energy astrophysical event, the process of nuclei rapidly gathering neutrons is known as the **$r$-process** or [**rapid neutron capture process**](https://en.wikipedia.org/wiki/R-process) is responsible for the creation of many elements heavier than iron. There is also another process known as the [**slow neutron capture process**](https://en.wikipedia.org/wiki/S-process), or the **$s$-process**. The difference between the $r$ and $s$ processes, for our purposes, is minor and the difference between the two is only in how quickly they happen.

A natural question to ask is "How does this create the rich variety of elements? This only tells me that there should be some incredibly neutron rich isotopes!". This is very true, however, the other half of the $r$ and $s$ processes is that they create incredibly _unstable_ neutron rich isotopes. And what do unstable isotopes do? Well, they $\beta$ decay of course! In this case, our neutrons will $\beta$ decay by spitting out an electron and turning into a proton -- thus creating a new element! Of course, if this happens too much, there is also a (much rarer) possibility that a proton will emit a positron thereby becoming a neutron. These processes are described for arbitrary atoms $X$, $Y$, and $W$ below, first $\beta^-$ decay

\begin{equation}
^{A}_ZX^N  \rightarrow ^{A}_{Z+1} Y^{N-1} + e^- + \bar{\nu}
\end{equation}

and then $\beta^+$ decay

\begin{equation}
^{A}_ZX^N  \rightarrow ^{A}_{Z-1} W^{N+1} + e^+ + \nu
\end{equation}

So essentially, the idea of stellar nucleosynthesis is that nuclei gather excess neutrons and then $\beta$ decay to stability. During an actual stellar nucleosynthesis event however, there are many other processes happening simultaneously that complicates the process, but these main events in the $r$ and $s$ processes are sufficient for the following demonstration.

## Question
1. What other potential processes have we ignored in this description?

## A (very) Simplified Model

To get a basic understanding of the life cycle of a nucleon trapped in the middle of the $r$ and $s processes, we're going to construct a toy model[1](#footnote1) of stellar nucleosynthesis that should produce some interesting visuals.

First, we'll start with a with a generic atom, any old atom will do, and we'll specify and arbitrary number of neutrons $N$ and an arbitrary number of protons $Z$. Once the atom is specified, we're going to assume it is trapped inside the $r$ or $s$ process and then we will toss a coin as to whether or not it captures a neutron, or beta decays. If it captures a neutron we'll increase the neutron count by one, if not we'll beta decay and lose a neutron and gain a proton, outlined in the flowchart below

>

> The image above depicts the calculation process required in order to create our toy model of nucleosynthesis. The green box outlines the calculation process when protons are not allowed to decay, and the gray box outlines the process when protons are allowed to decay. To begin we have an initial number of neutrons $N$ and an initial number if protons $Z$. We then draw a random number $r$ uniformly from the range $[0,1]$, and compare that to our neutron capture probability $p_{NC}$. If $r$ is greater than the probability of neutron capture, we gain a neutron, if not, we beta decay thereby losing a neutron and gaining a proton. Alternatively, if we're allowing proton decay, if our random number has dictated that we beta decay, we then draw a second random number $r_2$ uniformly from $[0,1]$ and compare it to our probability of proton decay $p_{PD}$. This comparison then decides which mode of decay that our atom will undergo. We however note that the exit condition in the widget you'll soon see is slightly different preventing cases where reaching $Z/N > 1$ may take a long time, or is changed to $N/Z > 1$ if there are more protons than neutrons at the beginning of the simulation.

-----

1: Here, toy model is understood to mean "wrong; but informative".

## Visualization

With the figure below you can visualize a nuclide's pathway to becoming a heavy element. We do note however that while this simulation will give you a basic idea of the process, the model described here is _far_ from realistic.

```python
# This is the exit condition of our loop, scaled to the original number of neutrons and protons
def Condition(Z,N, Z_0, N_0):
    if Z_0 >= N_0:
        return N/Z
    if N_0 > Z_0:
        return Z/N
# This is our toy model
def SimulateNewElement(N,Z, BetaNegative=False, ProbNegative = 0.1):
    # Initialize some lists and counters
    count = 0
    NArray = []
    ZArray = []
    min_iterations = 20
    N_0 = N
    Z_0 = Z
    # An infinite loop, relying on the break function to stop based on the conditions we've decided on
    while True:
        # Count how many times we've cycled and draw a random number
        count += 1
        r = random.uniform(0,1)
        # neutron capture
        if r >= 0.5:
            N = N + 1
            NArray.append(N)
            ZArray.append(Z)
        # beta decay
        elif r < 0.5:
            # draw a second random number to decide what kind of beta decay
            r2 = random.uniform(0,1)
            # Can only beta decay if we have a neutron and a proton
            if N > 0 and Z > 0:
                # If we're allowing proton decay
                if BetaNegative:
                    # Beta minus decay
                    if r2 < 1 - ProbNegative:
                        N = N - 1
                        Z = Z + 1
                    # beta plus decay
                    else:
                        N = N + 1
                        Z = Z - 1
                else:
                    # beta minus decay
                    N = N - 1
                    Z = Z + 1
            # save our counts at each iteration
            NArray.append(N)
            ZArray.append(Z)
        # Decide if we should leave
        if Condition(Z,N,Z_0,N_0) > 1.01 and count > min_iterations:
            if count > min_iterations:
                print("Reached Z = N line and passed minimum iterations, simulation finished")
            else:
                print("Ran minimum number of times and passed Z=N line.")
            break
        if count > 200:
            print("Taking too long, exiting early after 200 iterations")
            print("Try lowering the probability of negative beta decay")
            print("Or just simply running this simulation again")
            break
    return np.array(NArray), np.array(ZArray), np.linspace(0,count,count)
def TwoDAnimation(N,Z,ProtonDecayAllowed = True, ProbabilityOfProtonDecay = 0.0):
    N,Z,count = SimulateNewElement(N,
                                   Z,
                                   BetaNegative=ProtonDecayAllowed,
                                   ProbNegative=ProbabilityOfProtonDecay)
    steps = []
    data1 = [dict(type='scatter', visible = False, name = "Nuclide Path",
            marker = dict(color = 'rgb(194,91,86)', line = dict( color = 'rgb(194,91,86)')),
            mode = 'markers+lines', x = N[0:step], y = Z[0:step]) for step in range(len(N))]
    LinePoints = np.linspace(min(Z[0],N[0]), max(N[-1],Z[-1]), len(N))
    data2 = [dict(type='scatter', visible = False, name = "N = Z line",
            marker = dict(color = 'rgb(190,185,191)', line = dict( color = 'rgb(190,185,191)')),
            mode = 'lines', x = LinePoints, y = LinePoints) for step in range(len(N))]
    data2[1]['visible'] = True
    data = data1 + data2
    frames = data
    for i in range(len(data1)):
        step = dict(method = 'restyle', args = ['visible' ,[False] * len(data1)])
        step['args'][1][i] = True
        steps.append(step)
    sliders = [dict(active = 0, pad = {"t":50}, steps = steps,
                currentvalue = {'font': {'size': 20}, 'prefix': 'Nuclide:',
                                'visible': True, 'xanchor': 'right'},
                transition= {'duration': 300, 'easing': 'cubic-in-out'})]
    layout = dict(sliders=sliders, #updatemenus = updatemenus,
                  title = "Toy Model of Nucleosynthesis",
                  xaxis={"title":"Number of Neutrons"},
                  yaxis = {"title":"Number of Protons"})
    fig = py.graph_objs.Figure(data=data, layout=layout)
    py.offline.iplot(fig)
ProtonList = [i for i in range(5,100)]
NeutronList = [i for i in range(10, 105)]
interactive_plot2 = interactive(TwoDAnimation, Z=ProtonList, N=NeutronList, continuous_update=True)
interactive_plot2
```

In the figure above you can use the drop down menus to adjust the number of neutrons $N$ and the number of protons $Z$. By clicking `ProtonDecayAllowed` (enabled by default), you are allowing your protons to decay, with probability as selected by the `Probability...` slider. You can then view the nucleon's path through nucleosynthesis with the slider on the bottom. Also note by clicking and dragging you can zoom in on any point of the plot to your liking.

### Questions
1. Which way does the nucleon move on the path for neutron capture? $\beta^+$ decay? $\beta^-$ decay?
2. What does the path look like when you increase the probability of proton decay to fifty percent or more? Why? Be sure that `ProtonDecayAllowed` is clicked, and that you have at least 40 protons.

### Conclusion

In conclusion we covered the idea of radioactive decay as well as several modes of decay. We covered the general formulas for alpha and both flavors of beta decay. Using these ideas, we created a model which simulated the counts we might actually observe in nuclear decay and compared them to the theoretical curve for many unstable elements. This showed us that while the theory may predict smooth and equal counts, actual observation may be a little messier. This simulation can be used as preparation for actual experiments and to help understand what you would observe.

We also introduced the idea of neutron capture and the origin of the heavy elements. In order to help understand how the heavy elements are created, we created a toy model similar to the decay of an atom, except this time we simulated how an atom may capture neutrons and beta decay in order to create a heavier element.

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