## Splines

It should be clear at this point that if we want to approximate a function with an interpolating polynomial then selecting the nodes as something like Chebyshev points is essential.  However, data from many sources comes to us at node intervals that we do not control.  Most often, this is at regularly spaced intervals.  How should we interpolate between such data?  In most cases, the answer to this is that you should use splines.  Interpolation using splines is a generalization of "connecting the dots".  The most simple form, a linear interpolating spline, involves just connecting the data points with straight lines to find intermediate values.  This is illustrated below using a set of uniformly spaced points with the underlying function $f(x)=e^{-x^2}$.

This is also a common technique for interpolating between values in a table.  An obvious question is: Is this the best we can do with using different polynomials between each set of points (i.e. a function defined *piecewise*)?  Aside from the issue of absolute errors in the interpolation (i.e. $|f(x)-s_i(x)|$ where $s_i(x)$ is the polynomial defined on the interval between points $i$ and $i+1$), a linear interpolatory spline is not differentiable at the node points, typically called **knots** when discussing splines, which could be problematic for many applications.  The kinks are also visually unappealing in the plot.  It turns out that it is not that difficult to construct an interpolating spline that is continuous in both first and second derivatives at the knots by using piecewise cubic polynomial functions.

### Cubic interpolatory splines

To be more specific, we are looking for a set of cubic polynomials $\{ S_j(x) \}_{j=0}^{n-1}$ where our interpolatory function

$$S(x) = S_j(x)\qquad \text{if}\quad x_j \leq x \leq x_{j+1}.$$

This is illustrated below:

We want $S(x)$ to not only be a continuous interpolation function, but continuously differentiable at least two times.  This imposes the following four conditions at each interior node:

1. $S_j(x_j)=f(x_j),$

2. $S_j(x_{j+1})=S_{j+1}(x_{j+1}),$ which means that $S_j(x_{j+1})=f(x_{j+1})$ making use of the first condition,

3. $S_j'(x_{j+1})=S_{j+1}'(x_{j+1}),$

4. $S_j''(x_{j+1})=S_{j+1}''(x_{j+1}).$

Each of the $S_j(x)$ is a cubic polynomial which we will write in the form

$$S_j(x) = a_j+b_j(x-x_j)+c_j(x-x_j)^2+d_j(x-x_j)^3.$$ (splinepiece)

Each of the $n-1$ different $S_j$ requires four parameters so we will need $4(n-1)$ conditions to determine them.  As the last two conditions above only apply to the interior points, we are short two conditions overall.  These are filled in with two boundary conditions.  There are a number of possibilities for the boundary conditions.  A few common choices are

- $S''(x_0)=0$ and $S''(x_n)=0.$  This choice is called a *natural* cubic interpolatory spline.

- $S'(0) = A$ and $S'(x_n)=B$ with $A$ and $B$ specified.  This choice is called a *clamped* cubic interpolatory spline.

- $S_0'''(x_{1})=S_1'''(x_{1})$ and $S_{n-2}'''(x_{n-1})=S_{n-1}'''(x_{n-1})$.  This choice is called *not-a-knot* as it, together with the continuity of the lower derivatives already required at $x_1$ and $x_{n-1}$, forces $S_0(x)\equiv S_1(x)$ and $S_{n-2}(x)\equiv S_{n-1}(x)$.

Constructing the interpolatory spline requires using the above conditions to find the parameters in {eq}`splinepiece` for all $j$.

Condition 1 can be immediately exploited to find $a_j$ by noting that evaluating {eq}`splinepiece` at $x_j$ gives $S_j(x_j)=a_j$ and combining this with the first condition gives

>
>  $$ a_j=f(x_j),\qquad\qquad j=0,1,...,n-1.$$ (ajsolution)
>

We now evaluate $S_j$ and $S_{j+1}$ at $x_{j+1}$ and set them equal, as required by condition 2, to get

$$\begin{align}
a_{j+1} &= S_{j+1}(x_{j+1}) = S_j(x_{j+1}) = a_j + b_j(x_{j+1}-x_j) + c_j (x_{j+1}-x_j)^2 + d_j(x_{j+1}-x_j)^3\\
 &= a_j + b_j h_j + c_j h_j^2 + d_j h_j^3,\qquad\qquad j=0,...,n-2,
\end{align}
$$ (a_eqn)

where to simplify the notation going forward, we define

$$ h_j = (x_{j+1}-x_j). $$

We have not yet applied condition 1 and 2 to $S_{n-1}(x_{n+1})$.  However, if we define a "dummy" polynomial $S_n$ with $a_n=f(x_n)$ then {eq}`a_eqn` can be extended to $j=n-1$.

Before making use of the conditions on the derivatives of $S(x)$ note that

$$
\begin{align}
S'_j(x) &= b_j + 2 c_j(x-x_j) + 3 d_j(x-x_j)^2,\\
S''_j(x) &= 2 c_j + 6 d_j(x-x_j).
\end{align}
$$

As as this implies that $S_{0}'(x)=b_0$ it should be clear that a *clamped* spline boundary condition explicitly determines $b_0$ as the (given) derivative at $x_0$.  Similarly, a *natural* spline boundary condition explicity determines $c_0=0$.

Continuity of the derivative, condition 3, then implies

$$\begin{align}
b_{j+1} &= S_{j+1}'(x_{j+1}) = S_j'(x_{j+1}) = b_j + 2 c_j (x_{j+1}-x_j) + 3d_j(x_{j+1}-x_j)^2\\
 &= b_j + 2c_j h_j + 3d_j h_j^2, \qquad\qquad j=0,...,n-2.
\end{align}
$$ (b_eqn)

To extend this equation to $j=n-1$ we must make use of a boundary condition at $x_{j+1}$.  If we are constructing a *clamped* spline this would determine the derivative, and hence $b_{n}$ for the "dummy" polynomial $S_n$ if we extend this equation to $j=n-1$.  A *natural* spline instead just extends this equation to $j=n-1$ by allowing it to effectively determine the derivative $S_j'(x_{j+1})$ once we have determined the other parameters on the right hand side.

Similarly, continuity of the second derivative, condition 4, then implies

$$\begin{align}
c_{j+1} &= S_{j+1}''(x_{j+1}) = S_j''(x_{j+1}) = 2 c_j + 6d_j(x_{j+1}-x_j)\\
 &= 2c_j + 3d_j h_j, \qquad\qquad j=0,...,n-2.
\end{align}
$$ (c_eqn)

We can again extend this equation to $j=n-1$ for a *natural* spline by replacing $c_{j+1}$ with zero (the second derivative at $x_{j+1}$).  The extension for the other boundary conditions are slightly more complicated, but not much.  You will construct these in the problems.

Equations {eq}`a_eqn`, {eq}`b_eqn`, and {eq}`c_eqn` form a coupled linear set of equations for the parameters $b_j$, $c_j$, and $d_j$ given that we know $a_j$ (from {eq}`ajsolution`) and $h_j$.  We can simplify these equations further by eliminating $b_j$ and $d_j$ to get a set of equations for the $c_j$ alone (and then once these are known they can be used to deterime the other parameters).  We first eliminate the $d_j$ by rearranging {eq}`c_eqn`,

>
> $$ d_j = \frac{1}{3h_j}(c_{j+1}-c_j). $$ (djsolution)
>

Substituting this into {eq}`a_eqn` then gives

$$a_{j+1}=a_j + b_j h_j + \frac{h_j^2}{3}(2c_j+c_{j+1}),$$

which can be solved to give the $b_j$ in terms of the $c_j$ to get

>
> $$ b_j=\frac{1}{h_j}(a_{j+1}-a_j)-\frac{h_j}{3}(2c_j+c_{j+1}).$$ (bjsolution)
>

Substituting {eq}`bjsolution` and {eq}`djsolution` into {eq}`b_eqn` gives

$$ h_{j+1} c_{j+2} +2(h_{j+1} + h_j) c_{j+1}+h_j c_j = \frac{3}{h_{j+1}}(a_{j+2}-a_{j+1})-\frac{3}{h_j}(a_{j+1}-a_j),\qquad j=0,1,...n-2$$

This is a set of equations (for different $j$) and it is more useful to center it around $c_j$ than $c_{j+1}$. i.e. use the equation for $j\rightarrow j-1$

>
> $$ h_j c_{j+1} +2(h_j + h_{j-1}) c_j+h_{j-1} c_{j-1} = \frac{3}{h_j}(a_{j+1}-a_j)-\frac{3}{h_{j-1}}(a_j-a_{j-1}), \qquad j=1,2,...n-1$$
>

and using a boundary condition for $j=0$.  We can rewrite these equation and the boundary contions into a matrix equation as

$$
\begin{align}
  \left[ {\begin{array}{cccccc}
    1 & 0 & 0 & 0 & \cdots & 0\\
    h_0 & 2(h_0+h_1) & h_1 & 0 &\cdots & 0\\
    0 & h_1 & 2(h_1+h_2) & h_2 & 0 &\vdots \\
    \vdots & \ddots & \ddots & \ddots & \ddots & 0\\
    0 & \cdots & 0 & h_{n-1} & 2(h_{n-1}+h_{n-2}) & h_{n-2}\\
    0 & \cdots & 0 & 0 & h_{n-2} & 2(h_{n-2}+h_{n-1})\\
  \end{array} } \right]
  \left[ {\begin{array}{c}
  c_0 \\
  c_1 \\
  c_2 \\
  \vdots \\
  c_{n-2} \\
  c_{n-1} \\
  \end{array}} \right] =\\
  \qquad\qquad\qquad
  \left[ {\begin{array}{c}
  0 \\
  \frac{3}{h_1}(a_2-a_1)-\frac{3}{h_0}(a_1-a_0)\\
  \vdots \\
  \vdots \\
  \frac{3}{h_{n-2}}(a_{n-1}-a_{n-2})-\frac{3}{h_{n-3}}(a_{n-2}-a_{n-3})\\
  \frac{3}{h_{n-1}}(f(x_n)-a_{n-1})-\frac{3}{h_{n-2}}(a_{n-1}-a_{n-2})\\
  \end{array}} \right]
\end{align}
$$ (cjsolution)

for the *natural* cubic spline (the first equation is the boundary condition that sets $c_0=0$ and the last equation uses the "dummy" polynomial extension of the equations where $c_n=0$). This is a diagonally dominant tridiagonal system which means that solving this system via Gaussian elimination/LU factorization without pivoting will provide a unique solution and will be numerically stable.  In addition, this can be solved in $\mathcal{O}(n)$ flops using the [Thomas algorithm](https://en.wikipedia.org/wiki/Tridiagonal_matrix_algorithm) discussed in the chapter on linear systems.

So to construct our interpolatory spline $S(x)$ (or, in other words to construct the set $\{ S_j(x) \}_{j=0}^{n-1}$) we

1. Use Eq.{eq}`ajsolution` to obtain the $a_j$.

2. Solve the linear system in Eq.{eq}`cjsolution` to obtain the $c_j$.

3. Use Eq.{eq}`bjsolution` and Eq.{eq}`djsolution`, potentially making use of boundary condtions, to obtain the $b_j$ and $d_j$.

Once we have all the paramaters, to evaluate $S(x)$ at a specific point $x$ we

1. Determine which interval $[x_j,x_j+1)$ the value $x$ falls in.

2. Evaluate and return $S_j(x)$.

### Errors in Cubic Interpolatory Splines

As usual, we wish to ensure our errors are bounded and have some form of this error bound so it is clear what measures may, or may not, improve (or worsen) these errors.  This is provided by the following theorem which summarizes the results of [Hall and Meyer](https://doi.org/10.1016/0021-9045(76)90040-X) which also provides bounds on the derivatives.

**Theorem** Given $f\in C^4[a,b]$, $\max_{a\leq x \leq b} |f^{(4)}(x)|\leq M$, nodes at

$$ a = x_0 < x_1 < \cdots < x_n=b, $$

then if $S(x)$ is the cubic interpolatory spline with clamped end conditions

$$\begin{align}
\max_{a\leq x \leq b}|f(x)-S(x)| &\leq \frac{5 M}{384}(\max_{a\leq x \leq b} h_j)^4,\\
\max_{a\leq x \leq b}|f'(x)-S'(x)| &\leq \frac{M}{24}(\max_{a\leq x \leq b} h_j)^3,\\
\max_{a\leq x \leq b}|f''(x)-S''(x)| &\leq \frac{3 M}{8}(\max_{a\leq x \leq b} h_j)^2.
\end{align}
$$

As we can see, splines can be reasonably good for obtaining approximations to derivatives of the underlying function as well.

## Using Splines

Implementing splines requires solving a tridiagonal system.  While not hard to do, it is usually not worth the effort to implement all of this yourself.  Instead, you should use a library.  [SciPy contains an implementation for natural cubic interpolatory splines](https://docs.scipy.org/doc/scipy/reference/tutorial/interpolate.html) which we illustrate the use of below.

We first import the necessary libraries:

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

The first call to `interp1d` creates an instance of the class which sets up the interpolatory spline based on the provided data points.  The instance of this class defines a __call__ method which can later be treated like a function which interpolates between known data values to obtain unknown value. Behavior at the boundary can be specified at instantiation time. The default is actually a linear interpolatory spline but contains options to get a cubic.

```python
from scipy.interpolate import interp1d
import numpy as np

x = np.linspace(-2, 2, num=9, endpoint=True)
y = np.exp(-x**2)
f = interp1d(x, y)
f2 = interp1d(x, y, kind='cubic')
```

Now that we have the splines set up, we can make use of them to, for example, plot out the interpolatory function:

```python
xnew = np.linspace(-2, 2, num=41, endpoint=True)
import matplotlib.pyplot as plt
plt.plot(x, y, 'o', xnew, f(xnew), '-', xnew, f2(xnew), '--')
plt.legend(['data', 'linear', 'cubic'], loc='best')
plt.show()
```

```python

```


# Erlang_1,k
This notebook computes the CDF of an Erlang_1,k distribution

```
from sympy import *
import mpmath as mp
%matplotlib inline
init_printing()
p, mu, t, k  = symbols('p, mu, t, k')

```

```
pdf = p * mu * exp(-mu*t)  + (1 - p)* (mu**k) * ((t**( k - 1))/ factorial(k-1)) *exp(-mu*t)
pdf
```

```
cdf = integrate(pdf,t)

```

```
cdf
```

```

```


# Moran Processes

The evolutionary models discussed in the previous chapters assume an infinite population that can be divided in to infinitessimal parts. Finite populations can also be studied using a model called a Moran Process (first described in 1958).

---

## Moran process with neutral drift

[Video](https://youtu.be/OeMku85hwEc?list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

Consider a population of two types of fixed size $N$. This can be represented as a vector of the form: $(i, N-i)$ where $i\geq 0$ represents the number of individuals of the first type.

The term **neutral** drift refers to the fact that the two types reproduce at the same rate.

The Moran process is as follows:

- At a given time step: select a random individual for reproduction and a random individual for elimination
- The eliminated individual is replaced by a new individual of the same type as the individual chosen for reproduction.
- Proceed to the next time step.
- The process terminates when there is only one type of individual in the population.

---

Here is some simple Python code that simulates such a Process assuming an initial population of $(3, 3)$:

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

def neutral_moran(N, i=1, seed=0):
    """
    Return the population counts for the Moran process with neutral drift.
    """

    population = [0 for _ in range(i)] + [1 for _ in range(N - i)]
    counts = [(population.count(0), population.count(1))]
    np.random.seed(seed)
    while len(set(population)) == 2:
        reproduce_index = np.random.randint(N)
        eliminate_index = np.random.randint(N)
        population[eliminate_index] = population[reproduce_index]
        counts.append((population.count(0), population.count(1)))
    return counts

N = 6
plt.plot(neutral_moran(N=N, i=3, seed=6));
```

For different seeds we see we obtain different results. What becomes of interest is not the path but the end result: which strategy overcomes the presence of the other?

```python
def neutral_fixation(N, i=None, repetitions=10):
    """
    Repeat the neutral Moran process and calculate the fixation probability
    """
    fixation_count = 0
    for seed in range(repetitions):
        final_counts = neutral_moran(N=N, i=i, seed=seed)
        if final_counts[-1][0] > 0:
            fixation_count += 1

    return  fixation_count / repetitions
```

Let us take a look at probability of the first strategy taking over for different starting populations:

```python
probabilities = [neutral_fixation(N, i=i, repetitions=500) for i in range(1, N)]
plt.scatter(range(1, N), probabilities)
plt.xlabel("$i$")
plt.ylabel("$x_i$");
```

We see that as the initial population starts with more of a given type, the chance that that type "takes over" (becomes fixed) grows.

This Moran Process is a specific case of a Markov Process:

- A given state of the system can be described by a single integer $0\leq i\leq N$;
- The state to state transition probabilities are given by:

  $$
  \begin{aligned}
  p_{i, i-1}&=\frac{i(N - i)}{N^2}\\
  p_{i, i+1}&=\frac{i(N - i)}{N^2}\\
  p_{i, i}&=1 - p_{i, i-1} - p_{i, i+1}
  \end{aligned}
  $$

  We also have two absorbing states (when the Moran process ends):

  $$p_{00}=1\qquad p_{0i}=0\text{ for all }i>0$$

  $$
  p_{NN}=1\qquad p_{Ni}=0\text{ for all } N>i
  $$

  these transitions can be represented as a matrix. Here for example is the matrix for $N=6$:

```python
N = 6
p = np.zeros((N + 1, N + 1))
p[0, 0] = 1
p[N, N] = 1
for i in range(1, N):
    for j in [i - 1, i + 1]:
        p[i, j] = i * (N - i) / (N ** 2)
    p[i, i] = 1 - sum(p[i, :])
p.round(2)
```

    array([[1.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ],
           [0.14, 0.72, 0.14, 0.  , 0.  , 0.  , 0.  ],
           [0.  , 0.22, 0.56, 0.22, 0.  , 0.  , 0.  ],
           [0.  , 0.  , 0.25, 0.5 , 0.25, 0.  , 0.  ],
           [0.  , 0.  , 0.  , 0.22, 0.56, 0.22, 0.  ],
           [0.  , 0.  , 0.  , 0.  , 0.14, 0.72, 0.14],
           [0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 1.  ]])

The above corresponds to a particular type of Markov process called a Birth-Death process

---

## Birth death process

[Video](https://youtu.be/zJQQF2tq9AA?list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

A birth death process is a Markov process with the following properties:

- $p_{i,i+1}+p_{i,i-1}\leq 1$
- $p_{ii}=1-p_{i,i+1}-p_{i,i-1}$
- $p_{00}=1$ and $p_{NN}=1$

---

Thus we have two absorbing states: $\{0, N\}$. Let us denote by $x_i$ the probability of being in $state$ $i$ and eventually reaching state $N$.

We have the following linear system:

\begin{align}
    x_0&=0\\
    x_i&=p_{i,i-1}x_{i-1}+p_{ii}x_i+p_{i,i+1}x_{i+1}\text{ for all }0< i< N-1\\
    x_N&=1\\
\end{align}

---

## Theorem: Fixation probabilities for the birth death process

Given a birth death process as defined above, the fixation probability $x_i$ is given by:

$$x_i=\frac{1+\sum_{j=1}^{i-1}\prod_{k=1}^j\gamma_k}{1+\sum_{j=1}^{N-1}\prod_{k=1}^j\gamma_k}$$

where:

$$
\gamma_k = \frac{p_{k,k-1}}{p_{k,k+1}}
$$

### Proof

We have:

$$
\begin{aligned}
    p_{i,i+1}x_{i+1} & = -p_{i,i-1}x_{i-1} + x_i(1 - p_{ii}) \\
    p_{i,i+1}x_{i+1} & = p_{i,i-1}(x_{i} - x_{i-1}) + x_ip_{i,i+1} \\
    x_{i+1} - x_i    & = \frac{p_{i, i-1}}{p_{i, i+1}}(x_i-x_{i-1})=\gamma_i(x_i-x_{i-1})
\end{aligned}
$$

We observe that:

$$
\begin{aligned}
    x_2 - x_1 &= \gamma_1(x_1-x_{0})=\gamma_1x_1\\
    x_3 - x_2 &= \gamma_2(x_2-x_1)=\gamma_2\gamma_1x_1\\
    x_4 - x_3 &= \gamma_3(x_3-x_2)=\gamma_3\gamma_2\gamma_1x_1\\
              &\; \vdots & \\
    x_{i+1} - x_i &= \gamma_i(x_i-x_{i-1})=\prod_{k=1}^i\gamma_kx_1\\
               &\; \vdots & \\
    x_{N} - x_{N-1} &= \gamma_{N-1}(x_{N-1}-x_{N-2})=\prod_{k=1}^{N-1}\gamma_kx_1\\
\end{aligned}
$$

thus we have:

$$x_i=\sum_{j=0}^{i-1}x_{j+1}-x_j=\left(1+\sum_{j=1}^{i-1}\prod_{k=1}^j\gamma_k\right)x_1$$

we complete the proof by solving the following equation to obtain $x_1$:

$$x_N=1=\left(1+\sum_{j=1}^{N-1}\prod_{k=1}^j\gamma_k\right)x_1$$

---

In the case of neutral drift (considered above) we have:

$$p_{i,i-1}=p_{i,i+1}$$

thus:

$$
\gamma_i=1
$$

so:

$$
x_i=\frac{1+\sum_{j=1}^{i-1}\prod_{k=1}^j\gamma_k}{1+\sum_{j=1}^{N-1}\prod_{k=1}^j\gamma_k}=\frac{1+i-1}{1+N-1}=\frac{i}{N}
$$

```python
probabilities = [neutral_fixation(N, i=i, repetitions=500) for i in range(1, N)]
plt.scatter(range(1, N), probabilities, label="Simulated")
plt.plot(range(1, N), [i / N for i in range(1, N)], label="Theoretic: $i/N$", linestyle="dashed")
plt.xlabel("$i$")
plt.ylabel("$x_i$")
plt.legend();
```

---

## Fixation probability

The fixation probability in a Moran process is the probability that a give type starting with $i=1$ individuals takes over an entire population. We denote the fixation probabilities of the first/second type as $\rho_1$ and $\rho_2$ respectively and we have:

$$
\rho_1=x_1
$$

$$
\rho_2=1-x_{N-1}
$$

---

We will now consider a Moran process on a game:

---

## Moran process on a game

[Video](https://www.youtube.com/watch?v=TpqVoF1fBF8&index=43&list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

Consider a matrix $A\in\mathbb{R}^{m\times n}$ representing a game with two strategies.

$$
A=
\begin{pmatrix}
    a & b\\
    c & d
\end{pmatrix}
$$

The Moran process is as follows:

- At a given time step: all individuals play all other individuals.
- Obtain their fitness as given by the game.
- Randomly select an individual proportional to their fitness as an individual to be reproduced
- Uniformly select an individual to be replaced
- Proceed to the next time step.
- The process terminates when there is only one type of individual in the population.

Assuming $i$ individuals of the first type, the fitness of both types is given respectively by:

$$f_{1i}=\frac{a(i-1)+b(N-i)}{N-1}$$
$$f_{2i}=\frac{c(i)+d(N-i-1)}{N-1}$$

The transition probabilities are then given by:

$$p_{i,i+1}=\frac{if_{1i}}{if_{1i} + (N-i)f_{2i}}\frac{N-i}{N}$$
$$p_{i,i-1}=\frac{(N-i)f_{2i}}{if_{1i} + (N-i)f_{2i}}\frac{i}{N}$$

which gives:

$$\gamma_i=\frac{f_{2i}}{f_{1i}}$$

thus:

$$
x_i=\frac{1+\sum_{j=1}^{i-1}\prod_{k=1}^j\gamma_k}{1+\sum_{j=1}^{N-1}\prod_{k=1}^j\gamma_k}
$$

---

Here is some code to carry out this calculation:

```python
def theoretic_fixation(N, game, i=1):
    """
    Calculate x_i as given by the above formula
    """
    f_ones = np.array([(game[0, 0] * (i - 1) + game[0, 1] * (N - i)) / (N - 1) for i in range(1, N)])
    f_twos = np.array([(game[1, 0] * i + game[1, 1] * (N - i - 1)) / (N - 1) for i in range(1, N)])
    gammas = f_twos / f_ones
    return (1 + np.sum(np.cumprod(gammas[:i-1]))) / (1 + np.sum(np.cumprod(gammas)))
```

Here is an example of calculating $x_1$ for the following game for $N=4$:

[Video](https://www.youtube.com/watch?v=3sBVrnQhemE&index=44&list=PLnC5h3PY-znxMsG0TRYGOyrnEO-QhVwLb)

$$
A =
\begin{pmatrix}
    4 & 1\\
    1 & 4
\end{pmatrix}
$$

```python
A = np.array([[4, 1],
              [1, 4]])
theoretic_fixation(N=4, i=1, game=A)
```

    0.125

Applying the theorem gives:

$$
\begin{aligned}
f_{1i}&=\frac{4(i - 1) + 4 - i}{3} = \frac{4i-4+4-i}{3}=i\\
f_{2i}&=\frac{i + 4(3 - i)}{3} = \frac{12-3i}{3}=4-i
\end{aligned}
$$

$$
\gamma_i = \frac{f_{2i}}{f_{1i}}=\frac{4-i}{i}=\frac{4}{i}-1
$$

Thus:

$$
\begin{aligned}
x_1 & =\frac{1 + \sum_{j=1}^{0}\prod_{k=1}^{j}\gamma_k}{1 + \sum_{j=1}^{4 - 1}\prod_{k=1}^{j}\gamma_k}\\
    & =\frac{1}{1 + \sum_{j=1}^{3}\prod_{k=1}^{j}\gamma_k}\\
    & =\frac{1}{1 + \gamma_1 + \gamma_1\times \gamma_2 + \gamma_1 \times \gamma_2 \times \gamma_3}\\
    & =\frac{1}{1+3+3\times 1 + 3 \times 1\times \frac{1}{3}} = \frac{1}{1 + 3 + 3 + 1}=\frac{1}{8}\\
\end{aligned}
$$

Here is some code to simulate a Moran process.

```python
def moran(N, game, i=1, seed=0):
    """
    Return the population counts for
    the Moran process on a 2 by 2 game
    """
    population = [0 for _ in range(i)] + [1 for _ in range(N - i)]
    counts = [(population.count(0), population.count(1))]

    np.random.seed(seed)

    while len(set(population)) == 2:

        scores = []

        for i, player in enumerate(population):
            total = 0
            for j, opponent in enumerate(population):
                if i != j:
                    total += game[player, opponent]
            scores.append(total)

        total_score = sum(scores)
        probabilities = [score / total_score for score in scores]
        reproduce_index = np.random.choice(range(N), p=probabilities)

        eliminate_index = np.random.randint(N)
        population[eliminate_index] = population[reproduce_index]

        counts.append((population.count(0), population.count(1)))
    return counts

def fixation(N, game, i=None, repetitions=10):
    """
    Repeat the Moran process and calculate the fixation probability
    """
    fixation_count = 0
    for seed in range(repetitions):
        final_counts = moran(N=N, i=i, game=game, seed=seed)
        if final_counts[-1][0] > 0:
            fixation_count += 1
    return  fixation_count / repetitions
```

Here is one specific simulated process for the game with initial population: $(1, 7)$ where the invader manages to become fixed.

```python
N = 8
plt.plot(moran(N=N, i=1, seed=44, game=A));
```

Here is how the fixation probabilities vary for different initial populations:

```python
probabilities = [fixation(N, i=i, game=A, repetitions=500) for i in range(1, N)]
plt.scatter(range(1, N), probabilities, label="Simulated")
plt.plot(range(1, N), [i / N for i in range(1, N)], label="Neutral: $i/N$", linestyle="dashed")
plt.plot(range(1, N), [theoretic_fixation(N=N, i=i, game=A) for i in range(1, N)], label="Theoretic")
plt.xlabel("$i$")
plt.ylabel("$x_i$")
plt.legend();
```


# Gaussian Process (GP) smoothing

This example deals with the case when we want to **smooth** the observed data points $(x_i, y_i)$ of some 1-dimensional function $y=f(x)$, by finding the new values $(x_i, y'_i)$ such that the new data is more "smooth" (see more on the definition of smoothness through allocation of variance in the model description below) when moving along the $x$ axis.

It is important to note that we are **not** dealing with the problem of interpolating the function $y=f(x)$ at the unknown values of $x$. Such problem would be called "regression" not "smoothing", and will be considered in other examples.

If we assume the functional dependency between $x$ and $y$ is **linear** then, by making the independence and normality assumptions about the noise, we can infer a straight line that approximates the dependency between the variables, i.e. perform a linear regression. We can also fit more complex functional dependencies (like quadratic, cubic, etc), if we know the functional form of the dependency in advance.

However, the **functional form** of $y=f(x)$ is **not always known in advance**, and it might be hard to choose which one to fit, given the data. For example, you wouldn't necessarily know which function to use, given the following observed data. Assume you haven't seen the formula that generated it:

```python
%pylab inline
figsize(12, 6);
```

    Populating the interactive namespace from numpy and matplotlib

```python
import numpy as np
import scipy.stats as stats

x = np.linspace(0, 50, 100)
y = (np.exp(1.0 + np.power(x, 0.5) - np.exp(x/15.0)) +
     np.random.normal(scale=1.0, size=x.shape))

plot(x, y);
xlabel("x");
ylabel("y");
title("Observed Data");
```

### Let's try a linear regression first

As humans, we see that there is a non-linear dependency with some noise, and we would like to capture that dependency. If we perform a linear regression, we see that the "smoothed" data is less than satisfactory:

```python
plot(x, y);
xlabel("x");
ylabel("y");

lin = stats.linregress(x, y)
plot(x, lin.intercept + lin.slope * x);
title("Linear Smoothing");
```

### Linear regression model recap

The linear regression assumes there is a linear dependency between the input $x$ and output $y$, sprinkled with some noise around it so that for each observed data point we have:

$$ y_i = a + b\, x_i + \epsilon_i $$

where the observation errors at each data point satisfy:

$$ \epsilon_i \sim N(0, \sigma^2) $$

with the same $\sigma$, and the errors are independent:

$$ cov(\epsilon_i, \epsilon_j) = 0 \: \text{ for } i \neq j $$

The parameters of this model are $a$, $b$, and $\sigma$. It turns out that, under these assumptions, the maximum likelihood estimates of $a$ and $b$ don't depend on $\sigma$. Then $\sigma$ can be estimated separately, after finding the most likely values for $a$ and $b$.

### Gaussian Process smoothing model

This model allows departure from the linear dependency by assuming that the dependency between $x$ and $y$ is a Brownian motion over the domain of $x$. This doesn't go as far as assuming a particular functional dependency between the variables. Instead, by **controlling the standard deviation of the unobserved Brownian motion** we can achieve different levels of smoothness of the recovered functional dependency at the original data points.

The particular model we are going to discuss assumes that the observed data points are **evenly spaced** across the domain of $x$, and therefore can be indexed by $i=1,\dots,N$ without the loss of generality. The model is described as follows:

\begin{equation}
\begin{aligned}
z_i & \sim \mathcal{N}(z_{i-1} + \mu, (1 - \alpha)\cdot\sigma^2) \: \text{ for } i=2,\dots,N \\
z_1 & \sim ImproperFlat(-\infty,\infty) \\
y_i & \sim \mathcal{N}(z_i, \alpha\cdot\sigma^2)
\end{aligned}
\end{equation}

where $z$ is the hidden Brownian motion, $y$ is the observed data, and the total variance $\sigma^2$ of each ovservation is split between the hidden Brownian motion and the noise in proportions of $1 - \alpha$ and $\alpha$ respectively, with parameter $0 < \alpha < 1$ specifying the degree of smoothing.

When we estimate the maximum likelihood values of the hidden process $z_i$ at each of the data points, $i=1,\dots,N$, these values provide an approximation of the functional dependency $y=f(x)$ as $\mathrm{E}\,[f(x_i)] = z_i$ at the original data points $x_i$ only. Therefore, again, the method is called smoothing and not regression.

### Let's describe the above GP-smoothing model in PyMC3

```python
import pymc3 as pm
from theano import shared
from pymc3.distributions.timeseries import GaussianRandomWalk
from scipy import optimize
```

Let's create a model with a shared parameter for specifying different levels of smoothing. We use very wide priors for the "mu" and "tau" parameters of the hidden Brownian motion, which you can adjust according to your application.

```python
LARGE_NUMBER = 1e5

model = pm.Model()
with model:
    smoothing_param = shared(0.9)
    mu = pm.Normal("mu", sd=LARGE_NUMBER)
    tau = pm.Exponential("tau", 1.0/LARGE_NUMBER)
    z = GaussianRandomWalk("z",
                           mu=mu,
                           tau=tau / (1.0 - smoothing_param),
                           shape=y.shape)
    obs = pm.Normal("obs",
                    mu=z,
                    tau=tau / smoothing_param,
                    observed=y)
```

Let's also make a helper function for inferring the most likely values of $z$:

```python
def infer_z(smoothing):
    with model:
        smoothing_param.set_value(smoothing)
        res = pm.find_MAP(vars=[z], fmin=optimize.fmin_l_bfgs_b)
        return res['z']
```

Please note that in this example, we are only looking at the MAP estimate of the unobserved variables. We are not really interested in inferring the posterior distributions. Instead, we have a control parameter $\alpha$ which lets us allocate the variance between the hidden Brownian motion and the noise. Other goals and/or different models may require sampling to obtain the posterior distributions, but for our goal a MAP estimate will suffice.

### Exploring different levels of smoothing

Let's try to allocate 50% variance to the noise, and see if the result matches our expectations.

```python
smoothing = 0.5
z_val = infer_z(smoothing)

plot(x, y);
plot(x, z_val);
title("Smoothing={}".format(smoothing));
```

It appears that the variance is split evenly between the noise and the hidden process, as expected.

Let's try gradually increasing the smoothness parameter to see if we can obtain smoother data:

```python
smoothing = 0.9
z_val = infer_z(smoothing)

plot(x, y);
plot(x, z_val);
title("Smoothing={}".format(smoothing));
```

### Smoothing "to the limits"

By increading the smoothing parameter, we can gradually make the inferred values of the hidden Brownian motion approach the average value of the data. This is because as we increase the smoothing parameter, we allow less and less of the variance to be allocated to the Brownian motion, so eventually it aproaches the process which almost doesn't change over the domain of $x$:

```python
fig, axes = subplots(2, 2)

for ax, smoothing in zip(axes.ravel(), [0.95, 0.99, 0.999, 0.9999]):

    z_val = infer_z(smoothing)

    ax.plot(x, y)
    ax.plot(x, z_val)
    ax.set_title('Smoothing={:05.4f}'.format(smoothing))
```

### Interactive smoothing

Below you can interactively test different levels of smoothing. Notice, because we use a **shared Theano variable** to specify the smoothing above, the model doesn't need to be recompiled every time you move the slider, and so the **inference is fast**!

```python
from IPython.html.widgets import interact
@interact(smoothing=[0.01,0.99])
def plot_smoothed(smoothing=0.9):
    z_val = infer_z(smoothing)

    plot(x, y);
    plot(x, z_val);
    title("Smoothing={}".format(smoothing));
```

This example originally contributed by: Andrey Kuzmenko, http://github.com/akuz


# Wir trainieren nur bergab?

## Das Problem der Regression
Bei der Regressionsanalyse muss eine Modellfunktion gefunden werden, die zu einem gegebenen Satz von Datenpunkten N möglichst **genau** passt. Ein häufig verwendetes Maß für die Genauigkeit der Approximation ist die **Methode der kleinsten Quadrate** (engl. least squares approach). Der vertikale Abstand zwischen jedem Datenpunkt $(x_n,y_n)$ und der Ausgabe der Modellfunktion $m(x_n)$ wird durch Subtraktion der y-Werte der Datenpunkte von den vorhergesagten y-Werten der Modellfunktion berechnet (3).

\begin{align}
d_n & = m(x_n)-y_n \;\;\;\;\;\;\;\;\;\;\;        (3)
\end{align}

Diese Abstände werden dann quadriert und aufsummiert. Da wir die Qualität einer Approximation mit anderen Approximationen vergleichen wollen, die möglicherweise eine andere Anzahl von Datenpunkten haben, teilen wir die Summe noch durch die Gesamtzahl der Datenpunkte (**mittlerer quadratischer Fehler**). So erhalten wir unsere **Verlust**-Funktion (4). Unser Ziel ist es, diese Metrik so niedrig wie möglich zu halten, denn je niedriger der Verlust, desto besser die Approximation. Hier haben die Begriffe "Verlust" und "Fehler" die gleiche Bedeutung. Ein anderer häufig verwendeter Begriff ist "Kosten".

\begin{align}
Verlust & = \frac{1}{N} \sum_{n=0}^{N-1} {[m(x_n)-y_n]}^2 \;\;\;\;\;\;\;\;\;\;\;\; (4)
\end{align}

Hinweis: Beachten Sie, dass in Gl.(4) $x_0$ kein Merkmal wie in Gl.(1) aus W4V2 ist, sondern ein Datenpunkt des Datensatzes.

Wenn wir eine genaue Regression erreicht haben, können wir damit **Vorhersagen** machen. Wir werden unsere Neuronen auf einen gegebenen Satz von Punkten trainieren und sie dann verwenden, um neue Punkte vorherzusagen. Dazu werden wir dem trainierten Neuron neue x-Werte geben, zu denen es die y-Werte vorhersagen soll.

    Abb. 3 - Visualisierung des Abstands zur Modellfunktion

```python
# do not change
class SimpleNeuron:
    def __init__(self, plot):
        self.plot = plot #I am assigned the following plot
        self.plot.register_neuron(self) #hey plot, remember me

    def set_values(self, weight:float, bias:float):
        self.weight = weight
        self.bias = bias
        self.plot.update() #hey plot, I have changed, redraw my output

    def get_weight(self) -> float:
        return self.weight

    def get_bias(self) -> float:
        return self.bias

    def compute(self, x:float) -> float:
        self.activation = self.weight * x + self.bias
        return self.activation
```

Wir werden eine Funktion "loss" erstellen, die die Operation in Gleichung (4) durchführt. Als Argumente wird sie dazu ein Neuron-Objekt und einen Satz von Punkten erhalten.
- Für jeden Punkt, den wir ihr geben, trennt sie zunächst x- und y-Werte.
- Dann übergibt sie dem Neuron einen x-Wert und bittet das Neuron, eine Vorhersage für den y-Wert zu berechnen. (siehe $m(x_n)$)
- Dann subtrahiert sie den realen y-Wert von dem vorhergesagten y-Wert, wie in Gleichung (3), und erhält einen Abstand
- Anschließend quadriert sie den Abstand und summiert die quadrierten Abstände.
- Im letzten Schritt wird die Summe der quadrierten Abstände durch die Anzahl der verglichenen Punkte dividiert.

Führen Sie die nachfolgende Zelle aus, um eine Verlustfunktion zu definieren.

```python
# do not change
def loss(neuron:SimpleNeuron, points:dict) -> float:
    sum_squared_dist = 0

    for point_x, point_y in zip(points["x"], points["y"]):  # zip merges both points["x"] and points["y"]

        predicted_point_y = neuron.compute(point_x)
        dist = point_y - predicted_point_y
        squared_dist = dist ** 2
        sum_squared_dist += squared_dist

    loss = sum_squared_dist / len(points["y"])
    return loss
```

### Vorbereiten eines interaktiven Plots

Nachdem wir die notwendigen Bibliotheken importiert haben, werden wir nun eine interaktive Plot-Klasse einrichten. Diese soll die Ausgabe eines Neurons zeichnen, indem sie es auffordert, einen Satz von x-Werten zu berechnen. Das führt zu einem Satz von vorhergesagten y-Werten, die auf einer Ebene gezeichnet werden können. Wenn das Gewicht oder der Bias eines Neurons geändert wird, ruft das Neuron die "redraw"-Methode seines Plots auf, um ihn zu aktualisieren. Der Plot kann auch feste Punkte zeichnen. Interaktive Schieberegler werden verwendet, um die Gewichte und den Bias der Neuronenobjekten direkt zu ändern.

Hinweis: Die Plot-Klassen sind nicht Teil des Lernstoffs für dieses Praktikum.

Führen Sie die nachfolgenden Zellen aus, um die Bibliotheken zu importieren und einen interaktiven Plot zu definieren.

Hinweis: Wechseln Sie in die tf1-Umgebung. Wenn Sie einen eigenen Rechner verwenden oder diese Environment kein Plotly enthält, installieren Sie es. Dazu öffnen Sie das Terminal in tf1 und verwenden conda install ...

```python
# do not change
import numpy as np
import plotly.offline as plotly
import plotly.graph_objs as go
from ipywidgets import interact, Layout, HBox, FloatSlider
import time
import threading
```

```python
# do not change
# an Interactive Plot monitors the activation of a neuron or a neural network
class Interactive2DPlot:
    def __init__(self, points:dict, ranges:dict, width:int=800, height:int=400, margin:dict=dict(t=0, l=170), draw_time:float=0.05):
        self.idle = True
        self.points = points
        self.x = np.arange(ranges["x"][0], ranges["x"][1], 0.1)
        self.y = np.arange(ranges["y"][0], ranges["y"][1], 0.1)
        self.draw_time = draw_time
        self.layout = go.Layout(
            xaxis=dict(title="Input: x", range=ranges["x"], fixedrange=True),
            yaxis=dict(title="Output: y", range=ranges["y"], fixedrange=True),
            width=width,
            height=height,
            showlegend=False,
            autosize=False,
            margin=margin,
        )
        self.trace = go.Scatter(x=self.x, y=self.y)
        self.plot_points = go.Scatter(x=points["x"], y=points["y"], mode="markers")
        self.data = [self.trace, self.plot_points]
        self.plot = go.FigureWidget(self.data, self.layout)
        # self.plot = plotly.iplot(self.data, self.layout,config={"displayModeBar": False})

    def register_neuron(self, neuron:SimpleNeuron):
        self.neuron = neuron

    def redraw(self):
        self.idle = False
        time.sleep(self.draw_time)
        self.plot.data[0].y = self.neuron.compute(self.x)
        self.idle = True

    def update(self):
        print("Loss: {:0.2f}".format(loss(self.neuron, self.points)))
        if self.idle:
            thread = threading.Thread(target=self.redraw)
            thread.start()
```

Aufgabe 4.2.1: Das Neuron trainieren

Sie erhalten einen Satz mit 3 Punkten und ein Neuron, um eine Kurvenanpassung durchzuführen. Führen Sie die folgende Zelle aus.

 Verändern Sie das Gewicht und den Bias des Neurons mit den Schiebereglern, um den Verlust zu minimieren.

Tipp: Sie können die Schieberegler auch mit den Pfeiltasten auf Ihrer Tastatur verändern, nachdem Sie auf den Schieberegler geklickt haben.

```python
# do not change
points_linreg = dict(x=[1, 2, 3], y=[1.5, 0.7, 1.2])
ranges_linreg = dict(x=[-4, 4], y=[-4, 4])

linreg_plot = Interactive2DPlot(points_linreg, ranges_linreg)
simple_neuron = SimpleNeuron(linreg_plot)

slider_layout = Layout(width="90%")

interact(
    simple_neuron.set_values,
    weight=FloatSlider(min=-3, max=3, step=0.1, value = 0, layout=slider_layout),
    bias=FloatSlider(min=-3, max=3, step=0.1, value = 0, layout=slider_layout)
)

linreg_plot.plot
```

Frage 4.2.2: Was ist die optimale Kombination aus Gewicht und Bias?

Ihre Antwort:

### Vorbereiten des 3D-Plots
Wir sehen, dass die Suche nach dem geringsten Verlust ein **Parameteroptimierungsproblem** ist. Bisher können wir das Problem manuell lösen. Wollen wir aber neuronale Netze zur Lösung komplexerer Probleme verwenden, müssen wir einen Weg finden, diesen Prozess zu automatisieren.

Die Verlustfunktion wird sowohl mit dem festgelegten Gewicht als auch mit dem festgelegten Bias geändert. Diese Beziehung kann dreidimensional visualisiert werden, was uns weitere Einblicke geben kann, um einen Algorithmus zu konstruieren, der das Optimierungsproblem löst.
In dieser 3D-Ansicht werden logarithmische Skalen verwendet, um die Topographie hervorzuheben. Wir werden eine neue Funktion definieren, um den logarithmischen Verlust für einen Satz von Punkten zu berechnen.

Der Plot wird wie folgt definiert:
- Die **X-Achse** stellt die Gewichte dar.
- Die **Y-Achse** stellt den Bias dar.
- Die **Z-Achse** (Höhe) stellt den entsprechenden Verlustwert bei einer gegebenen Gewicht/Bias-Konfiguration dar. Zur besseren Verständlichkeit wird der Logarithmus des MSE-Verlusts angezeigt.
- Die **schwarze Kugel** stellt die aktuelle Gewicht/Bias-Konfiguration. Seine Höhe zeigt den Verlust dieser Konfiguration an.

Führen Sie die folgenden Zellen aus.

```python
# do not change
def log_mse(neuron:SimpleNeuron, points:dict) -> float:
    least_squares_loss = loss(neuron, points)
    return np.log10(least_squares_loss)
```

```python
# do not change
class Interactive3DPlot:
    def __init__(self, points:dict, ranges:dict, width:int=600, height:int=600, draw_time:int=0.1):
        self.idle = True
        self.points = points
        self.draw_time = draw_time
        self.threading = threading

        self.range_weights = np.arange(  # Array with all possible weight values in the given range
            ranges["x"][0], ranges["x"][1], 0.1
        )
        self.range_biases = np.arange(  # Array with all possible bias values in the given range
            ranges["y"][0], ranges["y"][1], 0.1
        )
        self.range_biases_t = self.range_biases[:, np.newaxis]  # Bias array transposed
        self.range_losses = []  # initialize z axis for 3D surface

        self.ball = go.Scatter3d(  # initialize ball
            x=[], y=[], z=[], hoverinfo="none", mode="markers", marker=dict(size=12, color="black")
        )

        self.layout = go.Layout(
            width=width,
            height=height,
            showlegend=False,
            autosize=False,
            margin=dict(t=0, l=0),
            scene=dict(
                xaxis=dict(title="Weight", range=ranges["x"], autorange=False, showticklabels=True),
                yaxis=dict(title="Bias", range=ranges["y"], autorange=False, showticklabels=True),
                zaxis=dict(title="Loss: log(MSE)", range=ranges["z"], autorange=True, showticklabels=False),
            ),
        )

        self.data = [
            go.Surface(
                z=self.range_losses,
                x=self.range_weights,
                y=self.range_biases,
                colorscale="Viridis",
                opacity=0.9,
                showscale=False,
                hoverinfo="none",
            ),
            self.ball,
        ]

        self.plot = go.FigureWidget(self.data, self.layout)

    def register_neuron(self, neuron:SimpleNeuron):
        self.neuron = neuron
        self.calc_surface()

        # height of 3d surface represents loss of weight/bias combination
        # In the 2D plot, x is an array from e.g. -4 to +4. But the weights and biases only have a single value
        # Here x will be the points to do regression and to calculate the loss on.
        # The surface is spanned by the arrays of weight and bias.

    def calc_surface(self):

        self.neuron.weight = (  #instead of 1 weight and 1 bias, let Neuron have an array of all weights and biases
            self.range_weights
        )
        self.neuron.bias = self.range_biases_t
        self.range_losses = log_mse(  # result: matrix of losses of all weight/bias combinations in the given range
            self.neuron, self.points
        )
        self.plot.data[0].z = self.range_losses

    def update(self):
        if self.idle:
            thread = threading.Thread(target=self.redraw)
            thread.start()

    def redraw(self):  # when updating, only the ball is redrawn
        self.idle = False
        time.sleep(self.draw_time)
        self.ball.x = [self.neuron.weight]
        self.ball.y = [self.neuron.bias]
        self.ball.z = [log_mse(self.neuron, self.points)]
        self.plot.data[1].x = self.ball.x
        self.plot.data[1].y = self.ball.y
        self.plot.data[1].z = self.ball.z
        self.idle = True
```

```python
# do not change
class DualPlot:
    def __init__(self, points:dict, ranges_3d:dict, ranges_2d:dict):
        self.plot_3d = Interactive3DPlot(points, ranges_3d)
        self.plot_2d = Interactive2DPlot(points, ranges_2d, width=400, height=500, margin=dict(t=200, l=30))

    def register_neuron(self, neuron:SimpleNeuron):
        self.plot_3d.register_neuron(neuron)
        self.plot_2d.register_neuron(neuron)

    def update(self):
        self.plot_3d.update()
        self.plot_2d.update()
```

Aufgabe 4.2.3: Das Neuron trainieren

 Sie erhalten den gleiche Satz von 3 Datenpunkten und erneut ein Neuron, um eine Kurvenanpassung durchzuführen. Führen Sie die folgende Zelle aus.

 Verändern Sie das Gewicht und den Bias des Neurons mit den Schiebereglern, um den Verlust zu minimieren.

 Beobachten Sie alle Änderungen.

Hinweis: Sie können den 3D-Plot drehen, indem Sie auf ihn klicken und den Cursor bewegen. Dabei müssen Sie mit dem Cursor innerhalb des Widgets bleiben.

```python
# do not change
ranges_3d = dict(x=[-2.5, 2.5], y=[-2.5, 2.5], z=[-1, 2.5])  # set up ranges for the 3d plot
plot_task2 = DualPlot(points_linreg, ranges_3d, ranges_linreg)  # create a DualPlot object to mange plotting on two plots
neuron_task2 = SimpleNeuron(plot_task2)  # create a new neuron for this task

interact(
    neuron_task2.set_values,
    weight=FloatSlider(min=-2, max=2, step=0.2, layout=slider_layout),
    bias=FloatSlider(min=-2, max=2, step=0.2, layout=slider_layout),
)

HBox((plot_task2.plot_3d.plot, plot_task2.plot_2d.plot))
```

Frage 4.2.4: Wo liegt im Allgemeinen die optimale Kombination aus Gewicht und Bias im 3D-Plot?

Ihre Antwort:

Frage 4.2.5: Wie steil ist das Tal an der Stelle der optimalen Gewichts- und Bias Kombination?

Ihre Antwort:

***
## Aktivierungsfunktionen
Bis jetzt ist unser Neuronenmodell, das aus Gewichten und Bias besteht, nur in der Lage, lineare Funktionen zu imitieren. Folglich können wir damit nur lineare Regression durchführen. Aktivierungsfunktionen erweitern unsere Möglichkeiten, indem sie dem Neuron eine zusätzliche Nichtlinearität hinzufügen. Mit ihnen können wir komplexere Funktionen modellieren. Die heutzutage am häufigsten verwendete Aktivierungsfunktion ist die Rectified Linear Unit, auch **ReLU** genannt. Sie gibt nur den Eingabewert aus, solange er größer als 0 ist. Ist er kleiner als 0, gibt sie 0 aus. Wir können diese Funktion bequem beschreiben, indem wir das Maximum des Eingabewertes und 0 nehmen. Der größere Wert von beiden wird als Ausgabe gewählt (5).

\begin{align}
f_{relu}(x) & = max(0,x) \;\;\;\;\;\;\;\;\;\;\; (5)
\end{align}

Führen Sie die folgende Zelle aus, um die ReLU Funktion zu definieren.

```python
# do not change
def relu(input_val:float) -> float:
    return np.where(input_val > 0, input_val, 0.0)
```

Wir können ein Neuron mit einer ReLU-Aktivierungsfunktion folgendermaßen zeichnen:

    Abb. 5 - Neuron mit ReLU-Aktivierungsfunktion visualisiert

Lassen Sie uns eine neue Klasse erstellen, um dieses Neuron in Python zu implementieren. Wir werden alle Eigenschaften eines Neurons von SimpleNeuron erben.
Wir ändern nur die Ausgabe, indem wir sie zunächst durch unsere ReLU-Funktion führen:

Aufgabe 4.2.6: Implementieren Sie ein komplettes künstlichen Neurons mit ReLU-Aktivierungsfunktion

 Vervollständigen Sie den folgenden Code eines künstliches Neuron, indem Sie die ReLU-Funktion von oben verwenden, um seine Aktivierung zu berechnen (wie in Abbildung 5).

Schauen Sie sich die einfache Neuronenklasse an und schreiben Sie eine ähnliche Berechnungsfunktion

Sie brauchen die relu-Funktion nicht neu zu implementieren und sollten nicht mehr als 1 Zeile hinzufügen müssen.

```python
class ReluNeuron(SimpleNeuron): #inherit from SimpleNeuron class

    def compute(self, inputs:list) -> float:
        # STUDENT CODE HERE

        # STUDENT CODE until HERE
        return self.activation
```

***
### Aufgabe: Nichtlineare Klimaregelung

Sie finden sich als Ingenieur bei der Firma "ClimaTronics" wieder. Ihre Firma möchte KI-Technologie implementieren, um ihr neues Klimasystem "Perfect Climate 9000" zu regeln. Obwohl das Problem leicht mit konventioneller Programmierung gelöst werden kann, möchte die Geschäftsleitung, dass Sie KI implementieren, um Investoren zu gewinnen. Sie müssen die folgenden Anforderungen erfüllen, die im Datenblattauszug visualisiert sind:

`Bei Temperaturen unter 25°C soll die Klimasteuerung ausgeschaltet bleiben. Bei einer Temperatur von 30°C soll sie 10% ihrer Kühlleistung erreichen. Zwischen 30°C und 40°C soll die Kühlleistung quadratisch mit der Temperatur ansteigen. Die Kühlleistung soll bei 40°C ihr Maximum erreichen.`

Führen Sie die nachfolgende Zelle aus, um ein interaktives Diagramm anzuzeigen.

```python
# do not change
points_climate = dict(x=[25.0, 27.5, 30.0, 32.5, 35, 37.5, 40.0], y=[0.0, 2.0, 10.0, 23.7, 43, 68.7, 100.0])

ranges_climate = dict(x=[-4, 45], y=[-4, 105])
climate_plot = Interactive2DPlot(points_climate, ranges_climate)
our_relu_neuron = ReluNeuron(climate_plot)

interact(
    our_relu_neuron.set_values,
    weight=FloatSlider(min=-10, max=10, step=0.1, value=0, layout=slider_layout),
    bias=FloatSlider(min=-200.0, max=200.0, step=1, value=0, layout=slider_layout),
)

climate_plot.plot
```

Frage 4.2.7: Wie wirkt sich eine Änderung des Gewichts auf die Ausgabefunktion aus, wenn der Bias auf 0,00 gesetzt wird?

Ihre Antwort:

Frage 4.2.8: Wie wirkt sich die Änderung des Bias auf die Ausgangsfunktion aus?

Ihre Antwort:

Frage 4.2.9: Bei welcher Temperatur beginnt die Klimatisierung, wenn sie das Gewicht auf 1,00 und den Bias auf -10, setzen?

Ihre Antwort:

Frage 4.2.10: Bei welcher Temperatur beginnt die Klimatisierung, wenn Sie das Gewicht auf 1,00 und den Bias auf -20 setzen?

Ihre Antwort:

Frage 4.2.11: Bei welcher Temperatur beginnt die Klimatisierung, wenn Sie das Gewicht auf 2,00 und des Bias auf -20 setzen?

Ihre Antwort:

Frage 4.2.12: Was ist die beste Gewicht/Bias-Konfiguration, die Sie finden konnten?

Ihre Antwort:

### Schlussfolgerung
Mit nur einem Neuron können wir den Einfluss von Gewicht und Bias leicht verstehen und nachvollziehen.
Aber unsere Ein-Neuron-Approximation reicht nicht aus, um die benötigte quadratische Beziehung genau zu approximieren.

***
## Neuronale Netze

Die Approximation kann durch die Verwendung mehrerer Neuronen verbessert werden. Anstatt nur ein Neuron für unsere Approximation zu verwenden, konstruieren wir ein neuronales Netzwerk. Wir werden zwei ReLU-Neuronen und ein Ausgangsneuron verwenden, das ebenfalls Gewichte hat. Nun können wir entscheiden, wie wir das Ergebnis der beiden ReLU-Neuronen in der Mitte gewichten wollen.

### Versteckte Schichten (engl. Hidden Layers)
In dem nachfolgenden neuronalen Netzwerk stellen die beiden Neuronen in der Mitte eine **versteckte Schicht** dar.

In der letzten Aufgabe hatten das Gewicht und der Bias einen leicht nachvollziehbaren Einfluss auf die Ausgabe.
Durch das Hinzufügen weiterer Neuronen wird die Beziehung zwischen den einzelnen Gewichten und Bias und der Ausgabe jedoch weniger nachvollziehbar.
Wir erhalten die Gewichte und Bias, indem wir sie einfach solange anpassen, bis das Ergebnis stimmt. Bei diesem Vorgang verlieren wir schnell den Überblick darüber, was wir eigentlich genau berechnen. Daher ist es schwierig, ein einzelnes Neuron aus diesem Netz zu isolieren und seine Verantwortung im System zu beschreiben.

Der Eingabewert wird mit den ersten Gewichten multipliziert und nach dem Hinzufügen der Bias auf eine Aktivierungsfunktion gegeben. Anschließend wird dieser Ausgang mit den zweiten Gewichten multipliziert. Versteckte Schichten können mehrfach hintereinander gestapelt werden. Dies gibt Raum für mehrere Berechnungsschritte und ermöglicht die Approximation komplexerer Funktionen.

Neuronale Netze, die mindestens eine versteckte Schicht verwenden, haben eine interessante Eigenschaft: Sie können zur Approximation einer beliebigen stetigen Funktion verwendet werden. _(Siehe "Weiterführende Literatur")_

Wir werden eine Klasse für neuronale Netze erstellen. Das Netzwerk wird vier Gewichte und zwei Bias haben.

Hinweis: Der Einfachheit halber und zur Wiederverwendbarkeit des Codes werden wir neuronale Netze so behandeln, wie wir einzelne Neuronen in den bisherigen Beispielen behandelt haben. Denken Sie daran, dass ein künstliches Neuron nur eine mathematische Funktion ist. Ein ganzes neuronales Netzwerk kann auch vollständig durch eine einzelne Funktion beschrieben werden, wie es auch bei der Berechnung der Aktivierung geschieht. Die Neuronen müssen nicht die konkrete Form von einzelnen Datenobjekten annehmen.

Führen Sie die folgende Zelle aus, um ein neuronales Netzwerk zu definieren.

```python
# do not change
class NeuralNetwork:
    def __init__(self, plot):
        self.plot = plot #I am assigned the following plot
        self.plot.register_neuron(self) #hey plot, remember me

    def set_config(self, w_i1:float, w_o1:float, b1:float, w_i2:float, w_o2:float, b2:float):
        self.w_i1 = w_i1
        self.w_o1 = w_o1
        self.b1 = b1
        self.w_i2 = w_i2
        self.w_o2 = w_o2
        self.b2 = b2
        self.show_config()
        self.plot.update()  # please redraw my output

    def show_config(self):
        print("w_i1:", self.w_i1, "\t| ", "w_o1:", self.w_o1,"\n")
        print("b1:", self.b1, "\t| ", "w_i2:", self.w_i2,"\n")
        print("w_o2:", self.w_o2, "\t| ", "b2:", self.b2,"\n")

    def compute(self, x:float)->float:
        self.prediction = (relu(self.w_i1 * x + self.b1) * self.w_o1
                         + relu(self.w_i2 * x + self.b2) * self.w_o2)
        return self.prediction
```

***
### Aufgabe: Nichtlineare Klimaregelung mit einem neuronalen Netzwerk

Führen Sie die folgende Zelle aus und passen Sie Gewichte und Bias an, um eine bessere Annäherung an die gewünschte Kurve als in der vorherigen Aufgabe zu erreichen.

```python
# do not change
climate_plot_adv = Interactive2DPlot(points_climate, ranges_climate)
our_neural_net = NeuralNetwork(climate_plot_adv)

interact(
    our_neural_net.set_config,
    w_i1=FloatSlider(min=-10, max=10, step=0.1, layout=slider_layout),
    w_o1=FloatSlider(min=-10, max=10, step=0.1,  layout=slider_layout),
    b1=FloatSlider(min=-200.0, max=200.0, step=1,  layout=slider_layout),
    w_i2=FloatSlider(min=-10, max=10, step=0.1, layout=slider_layout),
    w_o2=FloatSlider(min=-10, max=10, step=0.1,  layout=slider_layout),
    b2=FloatSlider(min=-200.0, max=200.0, step=1,layout=slider_layout),
)
climate_plot_adv.plot
```

Frage 4.2.13: Was ist die beste Konfiguration, die Sie finden konnten? (Kopie von oberhalb des Plots)

Ihre Antwort:

### Schlussfolgerung
Wir stellen fest, dass die quadratische Beziehung durch die Verwendung zusätzlicher Gewichte und Bias besser angenähert werden kann. Mit zwei ReLU Neuronen können wir eine Funktion mit zwei Knicken erstellen.
Die Komplexität der Suche nach den optimalen Gewichten/Biases nimmt jedoch mit jeder Variablen drastisch zu. Je leistungsfähiger unsere neuronalen Netze sein sollen, desto schwieriger wird die Optimierung.

***
## Backpropagation

Die Lösung für unser Optimierungsproblem lautet Backpropagation. Mit ihr können wir den Prozess der Anpassung von Gewichten und Bias automatisieren. In diesem Beispiel werden wir zu den Grundlagen zurückkehren und ein einfaches Neuron ohne Aktivierungsfunktion betrachten. Backpropagation funktioniert unter Hinzunahme der partiellen Ableitungen der Verlustfunktion in Bezug auf jedes Gewicht und jeden Bias im Netz. Diese können mit Hilfe der Kettenregel der Infinitesimalrechnung berechnet werden. Die Ausgabe des Netzwerks $\hat{y} = \hat{f}(x,\theta )$
(wenn $\hat{y}$ den vom neuronalen Netz vorhergesagten y-Wert bezeichnet) wird in der Forwardpropagation unter Anwendung der gegebenen Rechenregeln (Multiplizieren mit Gewichten, Summieren mit Bias und Aktivierungsfunktion, bis Sie den Ausgang erreichen) berechnet. Der Verlust wird dann aus dem vorhergesagten und dem Ground-Truth-Wert (tatsächlichem Wert) mit der Verlustfunktion berechnet. Mit diesem Verlust können Sie einfach die partiellen Ableitungen in der so genannten Backpropagation berechnen. Siehe zum Beispiel: [BackpropagationExample](https://ml-cheatsheet.readthedocs.io/en/latest/backpropagation.html)

An jedem Punkt zeigen der Bias- und der Gewichtsgradient in die Richtung des höheren Verlustes. Die Größe des Gradienten repräsentiert den Betrag der Verlustzunahme.

Angenommen, wir würden den Verlust in Abb. 6 __maximieren__: Alles, was wir tun müssen, ist, den partiellen Ableitungen zu folgen, indem wir sie zu unserem aktuellen Gewicht/Bias-Punkt addieren. In diesem Beispiel bedeutet das, dass wir das Gewicht stark verringern (siehe die Achsen in Abb. 6) und den Bias um einen geringen Betrag verringern (da seine partielle Ableitung eine geringere Größe hat).

Da wir aber mit dem Verlust nach unten gehen wollen, _subtrahieren_ wir den Gradienten von unserem aktuellen Punkt. Dadurch nähern wir uns dem Minimum entgegen. Im nächsten Schritt sind wir so weiter unten und nahe dem Tiefpunkt. Da wir dann aber immer noch nicht nah genug am Tiefpunkt sind, wiederholen wir diese Schritte einfach, bis wir das Minimum erreicht haben.

Das Gute an neuronalen Netzwerken ist, dass wir den Gradienten **analytisch** für alle möglichen Datenpunkte bestimmen können. Wir müssen ihn nicht durch numerische Methoden schätzen, wie z. B. durch die Berechnung des Verlusts zweier Gewicht/Bias-Kombinationen dividiert durch den "Schrittabstand" ("Euler-Methode"). Dieses Vorwissen über den Gradienten macht die Backpropagation vergleichsweise schnell. Leider können wir aber nicht analytisch die Gewicht/Bias-Kombination bestimmen, die die Verlustfunktion auf ihr Minimum bringt. Wir müssen sie immer noch iterativ über viele Schritte finden.

Jeder Schritt, den wir machen, wird eine **Epoche** genannt. (In diesem Fall sind _Trainingsschritte_ und _Epochen_ gleichwertig). Da es schwierig ist, festzustellen, ob das Minimum erreicht ist, geben wir die Anzahl der Epochen vor unserem Abstieg an und lassen das Programm dann einfach laufen.

Ist die Größe der Gradienten zu groß, werden wir nie ein Minimum erreichen. Das liegt daran, dass unser Algorithmus die Kugel (also den aktuellen Punkt) bei jedem Schritt zu stark bewegen will. Er wird um das Minimum oszillieren, aber nie dort ankommen. Im Extremfall kann die Bewegung sogar bis in die Unendlichkeit oszillieren. Um die Kontrolle über die Größe der Bewegung zu haben, wird der Gradient mit einem Faktor multipliziert, der **Lernrate** (engl. learning rate) genannt wird (auch "Schrittgröße" genannt im Gradientenabstieg). Indem wir die Lernrate auf einen optimalen Wert einstellen, können wir Oszillationen verhindern. Ist die Lernrate allerdings zu klein, wird das Netzwerk sehr lange brauchen, um zu "lernen", da sich dann die Gewichte und Bias nur sehr langsam ändern.

Die Anzahl der Epochen sowie die Lernrate sind so genannte **Hyperparameter**. Sie beeinflussen den Trainingsprozess, sind aber nicht Teil des Netzwerks selbst.

    Abb. 6 - Partielle Ableitungen der Verlustfunktion

### Backpropagation-Plot vorbereiten
Wir werden ein neues 3D-Diagramm erstellen, das die vergangenen Gewichts-/Bias-/Verlustwerte darstellt, während wir versuchen, den Verlust Schritt für Schritt zu optimieren. Die schwarze Kugel hinterlässt eine Spur ihrer vergangenen Werte. Führen Sie die nachfolgende Zelle aus, um das Plotten der Backpropagation-Schritte zu aktivieren.

```python
# do not change
plot_backprop = DualPlot(points_linreg, ranges_3d, ranges_linreg)
trace_to_plot = go.Scatter3d(x=[], y=[], z=[], hoverinfo="none", mode="lines", line=dict(width=10, color="grey"))

plot_backprop.plot_3d.data.append(trace_to_plot)  # Expand 3D Plot to also plot traces
plot_backprop.plot_3d.plot = go.FigureWidget(plot_backprop.plot_3d.data, plot_backprop.plot_3d.layout)
plot_backprop.plot_3d.draw_time = 0

def redraw_with_traces(plot_to_update:DualPlot, neuron:SimpleNeuron, trace_list:dict, points:dict):  # executed every update step
    plot_to_update.plot_3d.plot.data[2].x = trace_list["x"]
    plot_to_update.plot_3d.plot.data[2].y = trace_list["y"]
    plot_to_update.plot_3d.plot.data[2].z = trace_list["z"]
    plot_to_update.plot_3d.plot.data[1].x = [neuron.weight]
    plot_to_update.plot_3d.plot.data[1].y = [neuron.bias]
    plot_to_update.plot_3d.plot.data[1].z = [log_mse(neuron, points)]
    plot_to_update.update()

def add_traces(neuron:SimpleNeuron, points:dict, trace_list:dict):  # executed every epoch
    trace_list["x"].extend([neuron.weight])
    trace_list["y"].extend([neuron.bias])
    trace_list["z"].extend([log_mse(neuron, points)])
```

***
## DIY Backpropagation

Um Backpropagation durchzuführen, müssen Sie zunächst die partiellen Ableitungen der Verlustfunktion des "einfachen Neurons" in Abhängigkeit von Gewicht und Bias bestimmen. Danach müssen Sie herausfinden, wie Sie die Gewichte und Bias richtig an den auf die Lernrate skalierten Gradienten anpassen.
Am Ende dieser Übung können Sie Ihre Ergebnisse durch Training verifizieren. Wenn Sie die erwartete Leistung (Benchmark) erreichen, ist Ihr Algorithmus korrekt.

Der Algorithmus arbeitet mit einem Dictionary von Punkten mit der Form von: [points_linreg](#points_linreg).

Aufgabe 4.2.14: Bestimmen Sie die Steigung analytisch!!

 Vervollständigen Sie die folgende Funktion selbst.

Es gibt mehrere Lösungen, Ihr Algorithmus kann das Gewicht und den Bias in die richtige Richtung anpassen, obwohl die Berechnung des Gradienten falsch ist.

 Benchmark: Wenn Sie nach 100 Epochen und mit einer Lernrate von 0,03 einen Verlust von 0,22 erreichen können, ist Ihre Lösung richtig

```python
def simple_neuron_loss_gradient(neuron:SimpleNeuron, points:dict)->dict:

    gradient_sum = dict(weight=0, bias=0) # contains the sum of the weight and bias gradient
    for point_x, point_y in zip(points["x"], points["y"]):  # for each point
            # Hint: point_x and point_y are the current point values

        gradient_sum["weight"] += ( # sum up the gradient for each point

            # STUDENT CODE HERE

            # STUDENT CODE until HERE
        )

        gradient_sum["bias"] += (
            # STUDENT CODE HERE

            # STUDENT CODE until HERE
        )

    gradient = dict(weight=gradient_sum["weight"] / len(points["x"]), bias=gradient_sum["bias"] / len(points["x"]))
    return gradient
```

Aufgabe 4.2.15: Das Neuron anpassen

Nachdem Sie den Gradienten ermittelt haben, müssen Sie das Gewicht und den Bias des Neurons basierend auf den partiellen Ableitungen und der Lernrate anpassen. Sie sollten Ihre Ergebnisse überprüfen, indem Sie das Netz unten trainieren.

 Vervollständigen Sie die nachfolgende Funktion selbst.

```python
def adjust_neuron(neuron:SimpleNeuron, gradient:dict, learning_rate:float):
    # STUDENT CODE HERE

    # STUDENT CODE until HERE
```

### Trainingsprozess definieren

```python
# do not change
def train(neuron:SimpleNeuron, points:dict, epochs:int, learning_rate:float, redraw_step:int, trace_list:dict):
    redraw_with_traces(neuron.plot, neuron, trace_list, points)
    for i in range(1, epochs + 1):  # first Epoch is Epoch no.1
        add_traces(neuron, points, trace_list)
        gradient = simple_neuron_loss_gradient(neuron, points)
        adjust_neuron(neuron, gradient, learning_rate)

        if i % redraw_step == 0:
            print("Epoch:{} \t".format(i), end="")
            redraw_with_traces(neuron.plot, neuron_backprop, trace_list, points)
```

Aufgabe 4.2.16: Hyperparameter wählen und trainieren

Wählen Sie eine optimale Lernrate und Anzahl der Epochen, indem Sie verschiedene Werte einstellen und die beiden nachfolgenden Zellen ausführen.

```python
learning_rate = 0.03 #keep this for benchmarking, change to play around
epochs = 100 # keep this for benchmarking, change to play around
redraw_step = 10 # update plot every n'th epoch. too slow? set this to a higher value (e.g. 100)

# these values are taken as parameters by the train function below

neuron_backprop = SimpleNeuron(plot_backprop)
HBox((plot_backprop.plot_3d.plot, plot_backprop.plot_2d.plot))
```

```python
#run this cell to test algorithm

np.random.seed(4) # keep this for benchmarking, remove to play around # TODO: Use np.RandomState !!!!!

neuron_backprop.set_values(  # set weight and bias randomly
    (5 * np.random.random() - 2.5), (5 * np.random.random() - 2.5)
)
trace_list1 = dict(x=[], y=[], z=[])

train(neuron_backprop, points_linreg, epochs, learning_rate, redraw_step, trace_list1)
```

**Benchmark:** Wenn Sie nach 100 Epochen und einer Lernrate von 0,03 einen Verlust von 0,22 erreichen können, ist Ihre Lösung korrekt.

**Beantworten Sie diese Fragen erst, wenn Ihr Algorithmus den Benchmark erreicht hat**

Frage 4.2.17: Was passiert, wenn Sie die Lernrate auf 0,18 setzen? Erklären Sie dieses Verhalten.

Ihre Antwort:

Frage 4.2.18: Welche Lernrate führt zu dem geringsten Verlust nach 100 Epochen mit lr=0,03?

Ihre Antwort:

## Machine/Deep Learning Notation

Wir haben bereits die Lernrate, die Hyperparameter sowie die Epoche kennengelernt. Nun sollen einige weitere Begriffe eingeführt werden.

Betrachten Sie einen Trainingssatz, den Sie an Ihr neuronales Netzwerk geben und dessen Gewichte Sie mit Hilfe von Backpropagation anpassen wollen. Wenn Sie Gewichte und Bias mit jeden Datenpunkt, der in einem Vorwärts- und Rückwärtsdurchlauf verwendet wurde, aktualisieren, spricht man von **Stochastischem Gradientenabstieg** (engl. Stochastic Gradient Descent) oder Online-Lernen. Wie Sie sich vielleicht schon denken könen, können Sie auch die Fehler mehrer Datenpunkte zu Gruppen einer definierten **Stapelgröße** (engl. batch size) zusammen addieren. Auf diese Weise können Sie mehrere Rückwärtsdurchläufe mit diesen größeren Teilmengen von Trainingsdaten durchführen. Dieses Verfahren wird **Batch Gradient Descent** genannt. Bei einer korrekten Berechnung würden Sie den gesamten Satz von Trainingsdatenpunkten für Ihre Vorwärtsdurchläufe verwenden und Fehler speichern, um ein Update zu berechnen (= regulärer Gradient Descent). Mit zunehmender Anzahl an Datenpunkten ist dies aber oft nicht mehr praktikabel, sodass hier Batch Gradient Descent und Stochastic Gradient Descent hilfreich sein können. Allerdings zeigen beide Verfahren während des Trainings unterschiedliche Auswirkungen auf das Modell.

Egal, welche Variante des Gradientenabstiegs Sie verwenden: Eine **Epoche** ist dann abgelaufen, wenn Ihre Trainingsdaten einmal vollständig zum Aktualisieren der Gewichte (in Teilmengen oder als Ganzes) verwendet wurden.

Wie Sie bereits im Abschnitt zur Performannz Evaluierung gelernt haben, kann ein Modell in Abhängigkeit von der Komplexität des Problems/Modells und der Menge der verfügbaren Daten over- oder underfitten. Overfitting kann in neuronalen Netzen mit **Regularisierung** behoben werden. In der kommenden Einheit zu Convolutional Neural Networks werden Sie einige dieser Techniken unter Zuhilfenahme der Deeplearning-Bibliothek Keras anwenden müssen. Die gängigsten Methoden sind neben der Reduzierung der Modellkomplexität oder der Vergrößerung des Datensatzes die **L1/L2-Regularisierung** oder **Dropout**.

Wenn Sie Ihr Modell nicht richtig trainieren können, könnte dies daran liegen, dass Ihr Modell mit **Explodierenden oder verschwindenden Gradienten** (engl. Exploding or Vanishing Gradients) zu kämpfen hat. Betrachten Sie ein sehr tiefes Modell, das mehrere versteckte Schichten enthält. Durch Verwenden des Verlustes am Ausgang sollen die Parameter des gesamten Netzwerks aktualisiert werden. Da Werte $>1$, die rekursiv multipliziert werden, zu größeren Updates führen, kann das Verhalten während des Trainings instabil werden. Das Training wird die Gewichte in früheren Schichten nicht aktualisieren, wenn partielle Ableitungen $<1$ sind und über viele Schichten multipliziert werden (Vanishing Gradient). Um den letzten Fall zu verhindern, der beim regulären Training und beim Verwenden von Sigmoid-Funktionen häufiger auftritt, wird die ReLU-Aktivierungsfunktion heutzutage standardmäßig in den versteckten Schichten verwendet. Desweiteren können auch fortgeschrittenere Methoden verwendet werden, wie z. B. **Residual/Skip-Verbindungen**.

***
### Weiterführende Lektüre: Neuronale Netze sind universelle Funktionsapproximatoren

Mathematisch kann bewiesen werden, dass neuronale Netze jede kontinuierliche Funktion approximieren können, solange sie mindestens eine versteckte Schicht aufweisen, nichtlineare Aktivierungsfunktionen verwenden und eine ausreichende (aber endliche) Zahl von Neuronen der versteckten Schicht verwenden.

https://www.sciencedirect.com/science/article/pii/089360809190009T?via%3Dihub

Kurt Hornik,\
Approximation capabilities of multilayer feedforward networks,\
Neural Networks,\
Band 4, Ausgabe 2,\
1991,\
Seiten 251-257

```python

```

Woche 4/2 Wir trainieren nur bergab.ipynb


Mathematical Optimization for Engineers

Lab 9 - Branch and Bound Method

In this exercise, we want to consider the following optimization problem

$$\begin{aligned}
        \min_{x_1,x_2} \quad - x_1 - 2 x_2 + 10 \\
        \mbox{s.t. } \quad 5 x_1 + 3 x_2 & \; \leq \; 15 \\
                                 x_2 & \; \leq \; 2 \\
                                 x_1,x_2 & \; \in \; \mathbb{N}_0,
\end{aligned}$$
where $\mathbb{N}_0 = \{0,1,2,3,\dots \}$.

1.  Sketch the feasible set of the optimization problem and the contour
    lines of the objective function.

2.  Use the Branch & Bound algorithm to solve the
    optimization problem.  You can solve the relaxed problems either
    geometrically or you can use the built-in function "linprog" from scipy.optimize)

3.  Assign the terms "lower bound" and "upper bound" to each solution of
    the relaxed problems and construct the branching tree.

The feasible set and the contour lines of the objective function are sketched in the Figure below. Note that the feasible set consists only of the red points.

In previous labs, we looked at continuous optimization problems where the feasible set was continuous. The methods we have learned so far cannot be applied to discrete feasible sets. This is the reason, why we appply the Branch & Bound method.

### Branching & bounding of nodes

  Node 1:

First of all, the integer constraints are relaxed by replacing them with

$$\begin{aligned}
        x_1 & \geq 0 \\
        x_2 & \geq 0
\end{aligned}$$

so that the feasible set looks like

Note that the feasible set of the relaxed problem consists of all the points inside and on the boundary of the yellow region, i.e. the relaxed problem is a continunous problem.

The resulting optimization problem is a linear program.

We see, that the optimal solution of this problem (blue point) is $x^* = \left( \begin{array}{{c}}
                            1.8 \\ 2 \\
                            \end{array} \right)$ with objective function
value $f^* = 4.2$.

Unfortunately, the optimal solution $x^*$ is not integer, thus this is not the optimal solution of the original problem (not even a feasible point, red in colour). On the other hand, the value of the objective function of node 1 is called a "**global** lower bound" on the value of the objective function of the original problem.

    Branching node 1:

Since $x^*$ is not integer, we perform a so-called "branching" step.
This means, that we take one non-integer variable (in this case $x_1$)
and branch it. This implies, that we create two subproblems: node 2 and
node 3. Node 2 additionally contains the constraint $$\label{connode2}
        x_1 \leq 1$$ and node 3 includes the constraint
$$\label{connode3}
        x_1 \geq 2$$

Note, that $x_1 ^* = 1.8$ lies between the integer
values 1 and 2.

  Node 2:

The optimal solution of node 2 is $x^* = \left( \begin{array}{{c}}
                            1 \\ 2 \\
                            \end{array} \right)$ with objective function
value $f^* = 5$.

Now, the optimal solution $x^*$ is integer, thus the
value of the objective function of node 2 is called an "upper bound".
Note that we do not know whether this is also the optimal solution of
the original optimization problem, since node 3 may contain a better
integer value than this one. Since we found an integer solution in node
2, we do not have to branch any more. Further branching will not lead to
better objective functions.

  Node 3:

The optimal solution of node 3 is $x^* = \left( \begin{array}{{c}}
                            2 \\ 1.\bar{6} \\
                            \end{array} \right)$ with objective function
value $f^* = 4.\bar{6}$.

Unfortunately, the optimal solution $x^*$ is
not integer, thus this is not the optimal solution of the original
problem. Thus, the value of the objective function of node 3 is only a
"**local** lower bound".

    Branching node 3

Since $x^*$ is not integer, we have to branch. This means, that we take one non-integer component from $x^*$ and create two subproblems: node 4 and node 5. Node 4 contains all constraints from node 3 and additionally the constraint
\begin{equation} \label{connode4}
		x_2 \leq 1
\end{equation}
and node 5 contains also all constraints from node 3 and additionally the constraint
\begin{equation} \label{connode5}
		x_2 \geq 2
\end{equation}

Note, that $x_2 ^* = 1.\bar{6}$ lies between the integer values 1 and 2.

  Node 4:

The optimal solution of node 4 is
$x^* = \left( \begin{array}{{c}}
							2.4 \\ 1 \\
							\end{array} \right)$ with objective function value $f^* = 5.6$.

The optimal solution $x^*$ is not integer, thus this is not the optimal solution of the original problem. Once again, the value of the objective function of node 4 is only a "**local** lower bound".

Normally, we would have to branch now again. However, we are lucky in this case. The value of the objective function of node 4 ($f^* = 5.6$) is greater than the value of the upper bound (node 2: 	$f^* = 5$). When branching node 4, the feasible set gets smaller, thus the values of the objective function will be at least $5.6$. This means we do not have to branch node 4 again and can skip it. The optimal solution of the original problem is not in the feasible set of node 4.

  Node 5:

The feasible set of node 5 is empty. Thus, node 5 can be skipped as well.

Here's a summary of what we have done so far:

It is very important to note, that the value of the lower bound on the objective value has to increase or stay constant (the upper bound can decrease or stay constant) while descending in the tree. Obviously, this is here the case: node 1 has a smaller objective value, than node 3. Node 3 has a smaller objective value than node 4.

The optimal value of the objective function of the original problem has to be at least as high as the value of the objective function of node 1. That's why we call the value of the objective function of node 1 a **global** lower bound. Node 3 has only a **local** lower bound, since it may be possible to find a smaller objective value in node 2 or its subtree.

If the optimal solution of some node is integer, we call the respective objective value an **upper** bound. In case of upper bounds, we do not distinguish between global or local upper bounds. In some sense, all upper bounds can be seen as global, since the objective value of the original problem is equal to the value of the best (smallest) upper bound.

Note, that these terms only refer to **minimization** problems.

# Windsurfing problem
You are an eager windsurfer and want to advance to racing competitivity. Currently, you do not own a racing board and you have a limited budget to spend. Therefore, you want to optimize on choosing the right board(s) and the right time on surfing lessons. The trainer offers you up to 10 hours of surfing lessons. The boards differ depending on the wind conditions. There can be strong wind or light wind.

All parameters are given in the template.

Your task is:

Formulate the optimization problem.
Hint: You can only use a board if you have bought it.  ;)

Solve the optimization problem using scipy.

```python
from scipy.optimize import minimize, Bounds, NonlinearConstraint
import numpy as np
```

```python
# use of nonlinear function as it is simpler to implement

def constraints(x):
    # return constraints
    c = np.empty(9)

    # parameters

    # variable names (renaming makes the following equations simpler)
    # continuous degree of freedom
    # your code here

    # discrete degree of freedom
    # buy descions
    # your code here

    # next uses of board
    # your code here

    # board and surf lesson costs
    p = [112, 50, 218, 25]

    # constraint on budget
    cost_board1 = p[0]
    cost_board2 = p[1]
    cost_board3 = p[2]
    cost_surf_lesson = p[3]

    # budget constraint
    # your code here

    # choose only one board for wind_high

    # choose only boards that you have bought
    # your code here

    # choose only one board for wind_high
    # your code here

    # choose only boards that you have bought
    # your code here

    return c

def average_speed_objective(x):

    # variable names (renaming makes the following equations simpler)
    # continuous degree of freedom

    # discrete degree of freedom
    # buy descions
    # your code here

    # next uses of board
    # your code here

    # next uses of board
    # your code here

    # parameters
    speed_board1_wind_high = 18.1
    speed_board2_wind_high = 25.8
    speed_board3_wind_high = 25.8
    speed_lesson_wind_high = 1.5

    speed_board1_wind_low = 24.3
    speed_board2_wind_low = 21.0
    speed_board3_wind_low = 24.3
    speed_lesson_wind_low = 0.1

    # wind high
    speed1 = use_board1_wind_high * speed_board1_wind_high \
            + use_board2_wind_high * speed_board2_wind_high\
            + use_board3_wind_high * speed_board3_wind_high\
            + speed_lesson_wind_high * surf_lesson_hours

    # wind low
    speed2 = use_board1_wind_low * speed_board1_wind_low \
            + use_board2_wind_low * speed_board2_wind_low\
            + use_board3_wind_low * speed_board3_wind_low\
            + speed_lesson_wind_low * surf_lesson_hours

    return -(speed1+speed2)/2  # for maximization

```

```python
def print_sol(x):
    # variable names (renaming makes the following equations simpler)
    # continuous degree of freedom

    # discrete degree of freedom
    # buy descions
    # your code here

    # next uses of board
    # your code here

    # next uses of board
    # your code here

    print('surf_lesson_hours ', "{:.1f}".format(x[0]))
    print('buy_board_1 ', "{:.1f}".format(x[1]))
    print('buy_board_2 ', "{:.1f}".format(x[2]))
    print('buy_board_3 ', "{:.1f}".format(x[3]))
    print('use_board1_wind_high ', "{:.1f}".format(x[4]))
    print('use_board2_wind_high ', "{:.1f}".format(x[5]))
    print('use_board3_wind_high ', "{:.1f}".format(x[6]))
    print('use_board1_wind_low ', "{:.1f}".format(x[7]))
    print('use_board2_wind_low ', "{:.1f}".format(x[8]))
    print('use_board3_wind_low ', "{:.1f}".format(x[9]))

```

```python
# solve problem
budget = 400
# either one board and lessons, or two boards or two boards and lessons
constraint_lower_bounds = # your code here
constraint_upper_bounds = # your code here

# 'Nonlinear constraints'
# your code here

# Bounds on decision variables
# your code here

# call optimization routine (SLSQP)
# your code here

print_sol(sol.x)
```

```python
# Branching steps
```


Text provided under a Creative Commons Attribution license, CC-BY.  All code is made available under the FSF-approved BSD-3 license.  (c) Lorena A. Barba, Gilbert F. Forsyth 2017. Thanks to NSF for support via CAREER award #1149784.
[@LorenaABarba](https://twitter.com/LorenaABarba)

12 steps to Navier–Stokes
=====
***

We continue our journey to solve the Navier–Stokes equation with Step 4. But don't continue unless you have completed the previous steps! In fact, this next step will be a combination of the two previous ones. The wonders of *code reuse*!

Step 4: Burgers' Equation
----
***

You can read about Burgers' Equation on its [wikipedia page](http://en.wikipedia.org/wiki/Burgers'_equation).

Burgers' equation in one spatial dimension looks like this:

$$\frac{\partial u}{\partial t} + u \frac{\partial u}{\partial x} = \nu \frac{\partial ^2u}{\partial x^2}$$

As you can see, it is a combination of non-linear convection and diffusion. It is surprising how much you learn from this neat little equation!

We can discretize it using the methods we've already detailed in Steps [1](./01_Step_1.ipynb) to [3](./04_Step_3.ipynb).  Using forward difference for time, backward difference for space and our 2nd-order method for the second derivatives yields:

$$\frac{u_i^{n+1}-u_i^n}{\Delta t} + u_i^n \frac{u_i^n - u_{i-1}^n}{\Delta x} = \nu \frac{u_{i+1}^n - 2u_i^n + u_{i-1}^n}{\Delta x^2}$$

As before, once we have an initial condition, the only unknown is $u_i^{n+1}$. We will step in time as follows:

$$u_i^{n+1} = u_i^n - u_i^n \frac{\Delta t}{\Delta x} (u_i^n - u_{i-1}^n) + \nu \frac{\Delta t}{\Delta x^2}(u_{i+1}^n - 2u_i^n + u_{i-1}^n)$$

### Initial and Boundary Conditions

To examine some interesting properties of Burgers' equation, it is helpful to use different initial and boundary conditions than we've been using for previous steps.

Our initial condition for this problem is going to be:

\begin{eqnarray}
u &=& -\frac{2 \nu}{\phi} \frac{\partial \phi}{\partial x} + 4 \\\
\phi &=& \exp \bigg(\frac{-x^2}{4 \nu} \bigg) + \exp \bigg(\frac{-(x-2 \pi)^2}{4 \nu} \bigg)
\end{eqnarray}

This has an analytical solution, given by:

\begin{eqnarray}
u &=& -\frac{2 \nu}{\phi} \frac{\partial \phi}{\partial x} + 4 \\\
\phi &=& \exp \bigg(\frac{-(x-4t)^2}{4 \nu (t+1)} \bigg) + \exp \bigg(\frac{-(x-4t -2 \pi)^2}{4 \nu(t+1)} \bigg)
\end{eqnarray}

Our boundary condition will be:

$$u(0) = u(2\pi)$$

This is called a *periodic* boundary condition. Pay attention! This will cause you a bit of headache if you don't tread carefully.

### Saving Time with SymPy

The initial condition we're using for Burgers' Equation can be a bit of a pain to evaluate by hand.  The derivative $\frac{\partial \phi}{\partial x}$ isn't too terribly difficult, but it would be easy to drop a sign or forget a factor of $x$ somewhere, so we're going to use SymPy to help us out.

[SymPy](http://sympy.org/en/) is the symbolic math library for Python.  It has a lot of the same symbolic math functionality as Mathematica with the added benefit that we can easily translate its results back into our Python calculations (it is also free and open source).

Start by loading the SymPy library, together with our favorite library, NumPy.

```python
import numpy
import sympy
```

We're also going to tell SymPy that we want all of its output to be rendered using $\LaTeX$. This will make our Notebook beautiful!

```python
from sympy import init_printing
init_printing(use_latex=True)
```

Start by setting up symbolic variables for the three variables in our initial condition and then type out the full equation for $\phi$.  We should get a nicely rendered version of our $\phi$ equation.

```python
x, nu, t = sympy.symbols('x nu t')
phi = (sympy.exp(-(x - 4 * t)**2 / (4 * nu * (t + 1))) +
       sympy.exp(-(x - 4 * t - 2 * sympy.pi)**2 / (4 * nu * (t + 1))))
phi
```

It's maybe a little small, but that looks right.  Now to evaluate our partial derivative $\frac{\partial \phi}{\partial x}$ is a trivial task.

```python
phiprime = phi.diff(x)
phiprime
```

If you want to see the unrendered version, just use the Python print command.

```python
print(phiprime)
```

    -(-8*t + 2*x)*exp(-(-4*t + x)**2/(4*nu*(t + 1)))/(4*nu*(t + 1)) - (-8*t + 2*x - 4*pi)*exp(-(-4*t + x - 2*pi)**2/(4*nu*(t + 1)))/(4*nu*(t + 1))

### Now what?

Now that we have the Pythonic version of our derivative, we can finish writing out the full initial condition equation and then translate it into a usable Python expression.  For this, we'll use the *lambdify* function, which takes a SymPy symbolic equation and turns it into a callable function.

```python
from sympy.utilities.lambdify import lambdify

u = -2 * nu * (phiprime / phi) + 4
print(u)
```

    -2*nu*(-(-8*t + 2*x)*exp(-(-4*t + x)**2/(4*nu*(t + 1)))/(4*nu*(t + 1)) - (-8*t + 2*x - 4*pi)*exp(-(-4*t + x - 2*pi)**2/(4*nu*(t + 1)))/(4*nu*(t + 1)))/(exp(-(-4*t + x - 2*pi)**2/(4*nu*(t + 1))) + exp(-(-4*t + x)**2/(4*nu*(t + 1)))) + 4

### Lambdify

To lambdify this expression into a useable function, we tell lambdify which variables to request and the function we want to plug them in to.

```python
ufunc = lambdify((t, x, nu), u)
print(ufunc(1, 4, 3))
```

    3.49170664206445

### Back to Burgers' Equation

Now that we have the initial conditions set up, we can proceed and finish setting up the problem.  We can generate the plot of the initial condition using our lambdify-ed function.

```python
from matplotlib import pyplot
%matplotlib inline

###variable declarations
nx = 101
nt = 100
dx = 2 * numpy.pi / (nx - 1)
nu = .07
dt = dx * nu

x = numpy.linspace(0, 2 * numpy.pi, nx)
un = numpy.empty(nx)
t = 0

u = numpy.asarray([ufunc(t, x0, nu) for x0 in x])
u
```

    array([4.        , 4.06283185, 4.12566371, 4.18849556, 4.25132741,
           4.31415927, 4.37699112, 4.43982297, 4.50265482, 4.56548668,
           4.62831853, 4.69115038, 4.75398224, 4.81681409, 4.87964594,
           4.9424778 , 5.00530965, 5.0681415 , 5.13097336, 5.19380521,
           5.25663706, 5.31946891, 5.38230077, 5.44513262, 5.50796447,
           5.57079633, 5.63362818, 5.69646003, 5.75929189, 5.82212374,
           5.88495559, 5.94778745, 6.0106193 , 6.07345115, 6.136283  ,
           6.19911486, 6.26194671, 6.32477856, 6.38761042, 6.45044227,
           6.51327412, 6.57610598, 6.63893783, 6.70176967, 6.76460125,
           6.82742866, 6.89018589, 6.95176632, 6.99367964, 6.72527549,
           4.        , 1.27472451, 1.00632036, 1.04823368, 1.10981411,
           1.17257134, 1.23539875, 1.29823033, 1.36106217, 1.42389402,
           1.48672588, 1.54955773, 1.61238958, 1.67522144, 1.73805329,
           1.80088514, 1.863717  , 1.92654885, 1.9893807 , 2.05221255,
           2.11504441, 2.17787626, 2.24070811, 2.30353997, 2.36637182,
           2.42920367, 2.49203553, 2.55486738, 2.61769923, 2.68053109,
           2.74336294, 2.80619479, 2.86902664, 2.9318585 , 2.99469035,
           3.0575222 , 3.12035406, 3.18318591, 3.24601776, 3.30884962,
           3.37168147, 3.43451332, 3.49734518, 3.56017703, 3.62300888,
           3.68584073, 3.74867259, 3.81150444, 3.87433629, 3.93716815,
           4.        ])

```python
pyplot.figure(figsize=(11, 7), dpi=100)
pyplot.plot(x, u, marker='o', lw=2)
pyplot.xlim([0, 2 * numpy.pi])
pyplot.ylim([0, 10]);
```

This is definitely not the hat function we've been dealing with until now. We call it a "saw-tooth function".  Let's proceed forward and see what happens.

### Periodic Boundary Conditions

One of the big differences between Step 4 and the previous lessons is the use of *periodic* boundary conditions.  If you experiment with Steps 1 and 2 and make the simulation run longer (by increasing `nt`) you will notice that the wave will keep moving to the right until it no longer even shows up in the plot.

With periodic boundary conditions, when a point gets to the right-hand side of the frame, it *wraps around* back to the front of the frame.

Recall the discretization that we worked out at the beginning of this notebook:

$$u_i^{n+1} = u_i^n - u_i^n \frac{\Delta t}{\Delta x} (u_i^n - u_{i-1}^n) + \nu \frac{\Delta t}{\Delta x^2}(u_{i+1}^n - 2u_i^n + u_{i-1}^n)$$

What does $u_{i+1}^n$ *mean* when $i$ is already at the end of the frame?

Think about this for a minute before proceeding.

```python
for n in range(nt):
    un = u.copy()
    for i in range(1, nx-1):
        u[i] = un[i] - un[i] * dt / dx *(un[i] - un[i-1]) + nu * dt / dx**2 *\
                (un[i+1] - 2 * un[i] + un[i-1])
    u[0] = un[0] - un[0] * dt / dx * (un[0] - un[-2]) + nu * dt / dx**2 *\
                (un[1] - 2 * un[0] + un[-2])
    u[-1] = u[0]

u_analytical = numpy.asarray([ufunc(nt * dt, xi, nu) for xi in x])
```

```python
pyplot.figure(figsize=(11, 7), dpi=100)
pyplot.plot(x,u, marker='o', lw=2, label='Computational')
pyplot.plot(x, u_analytical, label='Analytical')
pyplot.xlim([0, 2 * numpy.pi])
pyplot.ylim([0, 10])
pyplot.legend();
```

***

What next?
----

The subsequent steps, from 5 to 12, will be in two dimensions. But it is easy to extend the 1D finite-difference formulas to the partial derivatives in 2D or 3D. Just apply the definition — a partial derivative with respect to $x$ is the variation in the $x$ direction *while keeping $y$ constant*.

Before moving on to [Step 5](./07_Step_5.ipynb), make sure you have completed your own code for steps 1 through 4 and you have experimented with the parameters and thought about what is happening. Also, we recommend that you take a slight break to learn about [array operations with NumPy](./06_Array_Operations_with_NumPy.ipynb).

```python
from IPython.core.display import HTML
def css_styling():
    styles = open("../styles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

<link href='http://fonts.googleapis.com/css?family=Fenix' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Alegreya+Sans:100,300,400,500,700,800,900,100italic,300italic,400italic,500italic,700italic,800italic,900italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Source+Code+Pro:300,400' rel='stylesheet' type='text/css'>
<style>
    @font-face {
        font-family: "Computer Modern";
        src: url('http://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf');
    }
    div.cell{
        width:800px;
        margin-left:16% !important;
        margin-right:auto;
    }
    h1 {
        font-family: 'Alegreya Sans', sans-serif;
    }
    h2 {
        font-family: 'Fenix', serif;
    }
    h3{
		font-family: 'Fenix', serif;
        margin-top:12px;
        margin-bottom: 3px;
       }
	h4{
		font-family: 'Fenix', serif;
       }
    h5 {
        font-family: 'Alegreya Sans', sans-serif;
    }
    div.text_cell_render{
        font-family: 'Alegreya Sans',Computer Modern, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
        line-height: 135%;
        font-size: 120%;
        width:600px;
        margin-left:auto;
        margin-right:auto;
    }
    .CodeMirror{
            font-family: "Source Code Pro";
			font-size: 90%;
    }
/*    .prompt{
        display: None;
    }*/
    .text_cell_render h1 {
        font-weight: 200;
        font-size: 50pt;
		line-height: 100%;
        color:#CD2305;
        margin-bottom: 0.5em;
        margin-top: 0.5em;
        display: block;
    }
    .text_cell_render h5 {
        font-weight: 300;
        font-size: 16pt;
        color: #CD2305;
        font-style: italic;
        margin-bottom: .5em;
        margin-top: 0.5em;
        display: block;
    }

    .warning{
        color: rgb( 240, 20, 20 )
        }
</style>

```python
r
```