```python
# [Nur Colab] Diese Zellen müssen nur auf *Google Colab* ausgeführt werden und installieren Packete und Daten
!wget -q https://raw.githubusercontent.com/KI-Campus/AMALEA/master/requirements.txt && pip install --quiet -r requirements.txt
!wget --quiet "https://github.com/KI-Campus/AMALEA/releases/download/data/data.zip" && unzip -q data.zip
```

# Inhaltsstoffe: Kann Spuren von Intelligenz enthalten

## Faltungsoperation

Wie der Name schon sagt, macht die Faltungsschicht (engl. convolutional layer) Gebrauch von Faltungen. Als kurze Zusammenfassung fasst dieses kleine Widget die Faltung von zwei rechteckigen Funktionen zusammen. Die Faltungsoperation ist durch die folgende Gleichung im kontinuierlichen Bereich gegeben:

\begin{equation*}
x(t) \ast y(t) = \langle x(t - \tau), y^{\ast}(\tau) \rangle_{\tau} = \int_{-\infty}^{+\infty} x(t -\tau) y(\tau) d \tau
\end{equation*}

```python
from ipywidgets import interact, interactive, fixed, interact_manual
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
from scipy import signal
figure_inches = 10

```

```python
def convolution(tau:tuple, width1:tuple, width2:tuple):

    x1 = np.linspace(-3.5, 3.5, num = 1000)
    dX = x1[1] - x1[0]
    rect1 = np.where(abs(x1) <= width1/2 , 1, 0)
    rect2 = np.where(abs(x1- tau)<= width2/2 , 1, 0)

    # Convolution of rect1 and rect2
    conv = np.convolve(rect1, rect2, 'same') * dX

    # Plotting
    plt.figure(1, figsize=(16.5,3.5))
    plt.plot(x1, rect1, 'b', label = '$rect_{1}$(t)')
    plt.plot(x1, rect2, 'r', label = '$rect_{2}$(t- $\\tau$)')
    x_gr = x1 - tau
    if tau <=0:
        index = np.where((np.absolute(x_gr)-np.absolute(tau))<=0.004)
        index = index[0][0]
    else:
        index = np.where(np.absolute(x_gr-tau)<=0.004)
        if not index[0].size > 0:
            index = [[999]]
        index = index[0][0]
    plt.plot(x_gr[:index]  , conv[:index], 'g', label = '$rect_{1}$ $\\ast$ $rect_{2}$')
    plt.axvline(x = tau, color= 'r', dashes = [6,2])
    plt.legend(bbox_to_anchor=(1.05, 1), loc=2, borderaxespad=0., prop={'size':15})
    plt.ylim(0,np.maximum(np.max(conv),np.max(rect1))+0.1)
    plt.xlim(-2.5, 2.5)
    plt.grid()
    plt.show()
```

```python
# Interactive Plot
interactive_plot = interactive(convolution, tau = (-2.0, +2.0, 0.25),
                               width1 = (0.25, 1.75, 0.25),
                               width2 = (0.25, 1.75, 0.25))
output = interactive_plot.children[-1]
interactive_plot
```

Natürlich erfolgt die Berechnung der Faltung nicht im kontinuierlichen Bereich. Daher verwendet numpy die folgende Formel für diskrete Faltung im 1-dimensionalen Raum:

\begin{equation*}
x_n \ast y_n = \sum_{i = -\infty}^{\infty} x_i  \  y_{n-i} = \sum_{i = -\infty}^{\infty} y_{i}  \ x_{n-i}
\end{equation*}

### Filter und Faltung in der Bildverarbeitung

Die Faltungsoperation kann auch in zwei Dimensionen angegeben werden:

\begin{equation*}
x_{mn} \ast \ast \ y_{mn} = \sum_{i = -\infty}^{\infty} \sum_{j = -\infty}^{\infty} x_{ij}  \  y_{m-i, n-j} = \sum_{i = -\infty}^{\infty} \sum_{j = -\infty}^{\infty} y_{ij}  \ x_{m-i, n-j}
\end{equation*}

In Bezug auf die Faltung von Bildern im diskreten Bereich ändern sich die Grenzen zu endlichen Werten, die die Größe der Bildform entsprechen.
Diese Operation kann dann wie folgt visualisiert werden:

Beachten Sie, dass die Faltungsschicht den Filter nicht spiegelt, wie in der konventionellen Signalverarbeitung. Von nun an werden Bilder oder räumliche Informationen in 3 Dimensionen definiert durch:

 - Height $H$
 - Weight $W$
 - Depth $d$ (channels)

Die diskrete Faltung reduziert die Bilddimensionen wie oben beschrieben. Die folgende Gleichung beschreibt die Reduktion selbst, wobei $K$ die `Kernel`- oder Filterdimensionen, $P$ zusätzliche Werte (meist zur Erhaltung der Ausgangsdimensionen, genannt `Padding`) und $S$ den `Stride` bezeichnet. Es gibt verschiedene Padding-Techniken, z.B. das Hinzufügen von Nullen (Zero Padding). Wenn der Stride größer als 1 ist, überspringt der Kernel bei der Faltung dazwischen liegende Werte.

\begin{align}
W_{i+1} = \dfrac{(W_{i}-K_{x}+2*P_{x})}{S_x}+1 \\
H_{i+1} = \dfrac{(H_{i}-K_{y}+2*P_{y})}{S_y}+1
\end{align}

Zunächst beginnen wir mit einem **grauen Bild** namens ascent, wobei W gleich der Breite des Bildes und H gleich der Höhe des Bildes ist. Da dieses Bild ein Graustufenbild ist, ist die Tiefe $d=1$.

Aufgabe 5.2.1:
Implementieren Sie die Funktion conv welche ein gegebenes Bild image_data mit einem gegebenenen Filter filter_kern filtert. Nehmen Sie an:

* Das Bild liegt entsprechend dem Beispiel (in der folgenden Zelle) als eine Liste von Listen vor
* Die Tiefe des Bildes ist 1

```python
def conv(image_data:list, filter_kern:list)->list:
    # STUDENT CODE HERE

    #STUDENT CODE until HERE
```

```python
test_input_data = [[0,0,0,0,0], [0,1,1,1,0], [0,0,2,0,0], [0,3,3,3,0], [0,0,0,0,0], [0,0,0,0,0]]
test_filter = [[0,0], [-1, 1]]
test_result = [[1,0,0,-1],[0,2,-2,0],[3,0,0,-3], [0,0,0,0], [0,0,0,0]]
found = conv(test_input_data, test_filter)
# Die folgende Zeile erzeugt einen Fehler, wenn die Ausgabe der Methode nicht mit der erwarteten übereinstimmt
assert found == test_result
```

### Filtertypen

Bevor wir nun in Richtung praktische Anwendung gehen, schauen wir uns grundlegende Filter an. Außerdem werden wir uns die Effekte der Filter anschauen - hierzu verwenden wir das folgende Bild:

```python
def read_image_as_array(image_path:str, new_size: tuple) -> np.array:
    img = Image.open(image_path).convert('L')
    img = img.resize(new_size,Image.ANTIALIAS)
    return np.array(img)

lama = Image.open('images/lama.png').convert('L')
lama = lama.resize((500,500),Image.ANTIALIAS)
plt.figure(figsize=(figure_inches, figure_inches))
plt.imshow(lama, cmap='gray')
data = np.array(lama)
```

#### Identitätsfilter

Der erste Filter entspricht der Identität, d.h. der Wert eines Pixel wird auf genau diesen abgebildet. Um dies zu erreichen wird ein quadratischer Filterkernel benötigt, dessen Größe ungerade ist. Außerdem ist der mittlere Eintrag 1 und alle anderen 0. Ein $3\times 3$-Filterkernel hat somit die Form:

$\left\lbrack\begin{array}{ccc} 0&0&0\\ 0&1&0\\ 0&0&0\end{array}\right\rbrack$

Und nun die angekündigte Anwendung auf das Bild:

```python
plt.figure(figsize=(figure_inches, figure_inches))
filter_kern_id = [[0,0,0],[0,1,0],[0,0,0]]
filtered_data = conv(data, filter_kern_id)
plt.imshow(filtered_data, cmap='gray')
```

#### Eckendetektoren

Die nächsten drei Filter ziehlen darauf ab, Ecken im Bild zu finden. Ziel hierbei ist es flächige Bereiche voneinander zu trennen. Die Filter sind oft nach deren Erfinder bzw. Entdecker benannt. In diesem Fall stellt der Sobel2 eine Verbesserung des Sobel1 dar - dieser kann zusätzlich zum horizontalen sowie vertikalen auch im $45^\circ$ Bereich messen.

Roberts: $\left\lbrack\begin{array}{ccc} 1&0&-1\\ 0&0&0\\ -1&0&1 \end{array}\right\rbrack$

Sobel1: $\left\lbrack\begin{array}{ccc} 0&-1&0\\ -1&4&-1\\ 0&-1&0\end{array}\right\rbrack$

Sobel2: $\left\lbrack\begin{array}{ccc} -1&-1&-1\\-1&8&-1\\ -1&-1&-1\end{array}\right\rbrack$

```python
filter_kern_roberts = [[1,0,-1], [0,0,0], [-1,0,1]]

plt.figure(figsize=(figure_inches, figure_inches))
data = read_image_as_array('images/lama.png', (500,500))
filtered_data_e1 = conv(data, filter_kern_roberts)

plt.imshow(filtered_data_e1, cmap='gray')
```

```python
filter_kern_sobel1 = [[0,-1,0], [-1,4,-1], [0,-1,0]]

plt.figure(figsize=(figure_inches, figure_inches))
data = read_image_as_array('images/lama.png', (500,500))
filtered_data = conv(data, filter_kern_sobel1)

plt.imshow(filtered_data, cmap='gray')
```

```python
filter_kern_sobel2 = [[-1,-1,-1], [-1,8,-1], [-1,-1,-1]]

plt.figure(figsize=(figure_inches, figure_inches))
data = read_image_as_array('images/lama.png', (500,500))
filtered_data = conv(data, filter_kern_sobel2)

plt.imshow(filtered_data, cmap='gray')
```

#### Bildschärfen

Der nächste Filter dient, wie der Name bereits vermuten lässt, dazu, dass Konturen im Bild schärfer werden.

$\left\lbrack\begin{array}{ccc} 0&-1&0\\ -1&5&-1\\ 0&-1&0 \end{array}\right\rbrack$

```python
filter_kern_sharp = [[0,-1,0], [-1,5,-1], [0,-1,0]]

plt.figure(figsize=(figure_inches, figure_inches))
data = read_image_as_array('images/lama.png', (500,500))
filtered_data = conv(data, filter_kern_sharp)

plt.imshow(filtered_data, cmap='gray')
```

#### Blur / Unschärfe

Die letzen beiden Filter dienen dazu, das Bild zu glätten. Der erste Filter wird auch als Box-Linear-Filter bezeichnet und ist verhätlinismäßig relativ simple aufgebaut. Der zweite Filter basiert auf einer Gaußverteilung und wird daher als Gauß-Filter bezeichnet.

Box-Linear-Filter: $\frac{1}{9} \left\lbrack\begin{array}{ccc}1&1&1\\ 1&1&1\\ 1&1&1\end{array}\right\rbrack$

Gauß-Filter: $\frac{1}{16} \left\lbrack\begin{array}{ccc}1&2&1\\ 2&4&2\\ 1&2&1\end{array}\right\rbrack$

```python
filter_kern_blf = [[1/9, 1/9, 1/9], [1/9, 1/9, 1/9], [1/9, 1/9, 1/9]]

plt.figure(figsize=(figure_inches, figure_inches))
data = read_image_as_array('images/lama.png', (500,500))
filtered_data = conv(data, filter_kern_blf)

plt.imshow(filtered_data, cmap='gray')
```

```python
filter_kern_gauss = [[1/16, 2/16, 1/16], [2/16, 4/16, 2/16], [1/16, 2/16, 1/16]]

plt.figure(figsize=(figure_inches, figure_inches))
data = read_image_as_array('images/lama.png', (500,500))
filtered_data = conv(data, filter_kern_gauss)

plt.imshow(filtered_data, cmap='gray')
```

### RGB-Bilder

Farbige Bilder können in der Regel durch RGB-Bilder dargestellt werden, wobei $d$ gleich 3 ist und enthält:

- R (rot),
- G (grün),
- B (blau)

Werte für alle Pixel in einem Bild.

```python
lama = Image.open('images/lama.png')
lama = np.array(lama)

fig, ax = plt.subplots(figsize=(figure_inches, figure_inches))
ax.set_title('Lama image 768x1024', fontsize = 15)
ax.imshow(lama, interpolation='nearest')
plt.tight_layout()
```

```python
# In general deep learning (and in tensorflow) Conv-layers will
# regard all channels and therefore use "cubic" filter

# The filter used here in the example down below is only using d=1 (two - dimensional) of the
# rgb image (therefore red), you can change [:,:,0] to [:,:,1] (green) and [:,:,2] (blue)!
# Try it! :)

prewitt_x =  np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]])

lama_x_prew = signal.convolve2d(lama[:,:,0], prewitt_x, boundary='symm', mode='same')
lama_x_prew = np.absolute(lama_x_prew)

fig, ax = plt.subplots(figsize=(figure_inches, figure_inches))
ax.set_title('Horizontale Ableitung des Lama Bildes', fontsize = 15)
ax.imshow(lama_x_prew, interpolation='nearest', cmap='gray')
plt.tight_layout()
```

#### Die Faltungsschicht (engl. Convolutional Layer)

Eine Faltungsschicht, welche die erste Schicht im Netzwerk sein könnte, ist im Bild oben dargestellt. Ihr Kernel oder Filter mit den Dimensionen $K_x \times K_y \times d$ enthält Gewichte, die während des Trainings aktualisiert werden und auch die Darstellung der Bilder verändern. Eine Aktivierungskarte (engl. activation map) entspricht einer Faltungsoperation mit einem bestimmten Filter und dem zugehörigen Eingangsbild oder den räumlichen Daten der vorherigen Schicht. In den meisten Fällen werden nicht nur ein, sondern mehrere Filter in einer Faltungsschicht gelernt, so dass es mehrere Aktivierungskarten gibt. In diesem speziellen Fall scheint die Ausgabegröße dieser Faltungsschicht im Vergleich zur Eingabegröße größer geworden zu sein. Infolgedessen werden häufig Pooling-Operationen angehängt, um die Daten innerhalb des Netzwerks zu reduzieren. Die nächste Schicht erhält dann wieder räumliche Informationen und verwendet Filter, um die räumlichen Informationen zu extrahieren und zu verändern.

**Idea**: _`Spärliche Verbindungen (engl. Sparse Connections)` (nicht vollständig verbundene Schichten wie bei einem MLP) sind als Kernel für große Datenstrukturen gegeben. Die Anzahl der lernbaren Gewichte sinkt!_

Vergleichen wir eine standardmäßige voll verbundene Schicht (engl. fully connected layer) eines MLP mit einer Faltungsschicht für ein reguläres farbiges Bild der Größe $256\times256\times3$:
- Erstes Hidden Layer in einer voll verbundenen Schicht:
    - Input Neuronen $\rightarrow$ $256*256*3$
    - Beginnen Sie z. B. mit der Hälfte der Neuronen im ersten Hidden Layer $\rightarrow$ $128*256*3$
    - Ergebnisse in Gewichte und Biases $\rightarrow$ $256*256*3*128*256*3 + 128*256*3 = 19.327.451.136$ Parameters

- Erste Faltungsschicht in einem faltigen neuronalen Netz: Standard 256 Filter (vernünftige Größe) der Größe $3\times3\times3$
    - Gewichte und Biases $\rightarrow$ $256 * 3 * 3 *3 + 256 = 7.168 $ Parameters

Trotzdem brauchen Faltungen mit räumlichen Blöcken wie in der obigen Abbildung noch Zeit, um verarbeitet zu werden.
Lokale Informationen werden nur nicht wie globale Abhängigkeiten in Hidden Layers verwendet!

Die **Vorteile** einer Faltungsschicht (`CONV`) gegenüber einer vollverknüpften Schicht sind die folgenden
 - Weniger Parameter für das Training
 - Nutzung der lokalen Strukturen des Bildes
 - Unabhängig von der Position des Merkmals im Bild

**Nachteile** von Faltungsschichten (`CONV`):
 - Informationen müssen räumliche Abhängigkeiten haben (wie bei einem menschlich erkennbaren Bild)

Beim Stapeln mehrerer Faltungsschichten hat ein Kernel der folgenden Faltungsschicht die Form $K_x \times K_y \times d$, wobei $d$ die Anzahl der Kanäle der vorherigen Schicht ist. Die Anzahl der Kanäle ist gegeben durch die Anzahl der verschiedenen Filter, die in der Faltungsschicht verwendet werden. Definiert man also eine Faltungsschicht mit z. B. $nb\_filters=64$, so legt man die dritte Dimension eines Filters in der nächsten Schicht fest. Denn im zweidimensionalen Fall expandiert der Filter immer auf die vorherige Kanaldimension. Betrachtet man CNNs für die Videoanalyse oder für Zeitreihen, so stößt man auf 3-dimensionale Faltungsschichten, die sich nicht nur in den Bilddimensionen bewegen, sondern in einer dritten Dimension (in diesem Fall: Zeit).

#### Die Poolingsschicht (engl. pooling layer)

                                     Quelle: http://cs231n.github.io/convolutional-networks/

Die Pooling Schicht ist ein Filter wie alle anderen Filter im neuronalen Faltungsnetzwerk. Allerdings mit der Ausnahme, dass sie ihre Gewichte nicht aktualisiert und eine feste Funktionsoperation durchführt. Die häufigste Pooling-Operation ist das Max-Pooling. Wie der Name schon sagt, wird im Bereich des Kerns nur der Maximalwert weitergegeben. Normalerweise entspricht der Stride den Dimensionen des Kernels. Das Max-Pooling wird nur auf die Höhe und Breite des Bildes angewendet, so dass die Kanaldimensionen nicht betroffen sind. Es wird verwendet, um räumliche Informationen zu reduzieren.

Aufgabe 5.2.2: Implementieren Sie die Funktion max_pool die Maxpooling durchführt. Gegeben ist wieder ein Grauwertbild image_data, d.h. es besitzt nur einen Kanal und Sie können annehmen, dass das Bilder wieder als eine Liste von Listen übergeben wird. Außerdem ist die Größe des Filters filter_size als Tupel und die stride als int gegeben.

```python
def max_pool(image_data:list, filter_size:tuple, stride:int)->list:
    # STUDENT CODE HERE

    # STUDENT CODE until HERE
```

```python
test_input_data = [[0,0,0,0,0], [0,1,1,1,0], [0,0,2,0,0], [0,3,3,3,0], [0,0,0,0,0], [0,0,0,0,0]]
test_filter_size = (2,2)
stride = 2
test_result = [[1,1],[3,3], [0,0]]

# The folgende Zeile erzeugt einen Fehler, wenn die Ausgabe der Methode nicht mit der erwarteten übereinstimmt
found = max_pool(test_input_data, test_filter_size, stride)
assert found == test_result
```

#### ReLU - Schicht oder Aktivierung
Die "RELU"-Schicht oder Aktivierung verwendet eine elementweise Aktivierungsfunktion auf das Raumvolumen an, wie auf jeden Knoten in einer Hidden Layer. Die Funktion kann als $max(0,x)$ angegeben werden und ist unten dargestellt. Betrachten Sie $\sigma(x)$ als die Aktivierungsfunktion.

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

def relu(x:float)->float:
    return np.maximum(0,x)
x = np.linspace(-10, 10, num = 1000)

plt.figure(2, figsize=(10,3.5))
plt.plot(x, relu(x), label='ReLU')
plt.title('The ReLU activation')
plt.legend()
plt.xlabel('x')
plt.ylabel('$\sigma(x)$')
plt.tight_layout()
plt.grid()
```

### Zusammenfassung

Die folgende Animation zeigt recht gut, wie ein Faltungsnetzwerk (engl. convolutional network) anhand des `MNIST`-Datensatzes funktioniert.
Nachdem die Faltungsschichten die Repräsentation der Bilder verändert haben, werden die endgültigen mehrdimensionalen Blöcke in ein langes Array gelegt (die Operation wird "Flattening" genannt) und an voll verbundene Schichten eines neuronalen Netzes weitergeleitet.

[MNIST-CLassification](http://scs.ryerson.ca/~aharley/vis/conv/flat.html)

#### Receptive Field

In der Animation bzw. Simulation von MNIST werden Abhängigkeiten, die als Linien zwischen mehr als zwei Schichten dargestellt werden, nicht abgebildet.
Dennoch ist es möglich, Beziehungen zwischen beliebigen Schichten innerhalb des Netzes darzustellen. Dadurch ist es möglich, ein gewisses Wissen oder eine Idee über die Anzahl der Faltungsschichten zu erhalten, die für eine Anwendung oder Aufgabe verwendet werden sollten. Betrachten Sie drei übereinander gestapelte Faltungsschichten wie im Bild unten. Ein Wert in der grünen Schicht bezieht sich auf 9 Eingangswerte. Folglich summiert sich ein Wert in der gelben Schicht auf 9 in der grünen Schicht. Ein Eintrag in der gelben Schicht wird also von mehr Werten beeinflusst als die grünen Aktivierungseinträge in Bezug auf das Eingangsbild. Dieser Bereich ist gelb dargestellt und deckt 49 Werte des Eingangsbildes ab. Um die Dimensionen während der Faltungen wie in üblichen CNNs beizubehalten, wurde ein Padding verwendet, um die Dimensionen der Matrix gleich zu halten. Die `Initialmatrix` ist dann von der Größe $7 \times 7$.

    Quelle:https://medium.com/mlreview/a-guide-to-receptive-field-arithmetic-for-convolutional-neural-networks-e0f514068807

Frage 5.2.3: Was ist der Hauptunterschied zwischen einer Faltungsschicht (engl. convolutional layer) und einer vollverknüpften Schicht (engl. fully-connected layer) und warum werden überhaupt Filter verwendet?

Ihre Antwort:

```python

```

Woche 5/2 Inhaltsstoffe Kann Spuren von Intelligenz enthalten.ipynb


```python
from __future__ import division
import numpy as np
import matplotlib.pyplot as plt
import numpy.matlib as mat
```

**Probabilistic Movement Primitives**
===================================

In this notebook we will write a class called **ProMP** which includes all the information required to define a ProMP as well as important methods which allow the manipulation of the MPs to show the potential of the probabilistic approach.

_______________________________________________________________________________

For stroke-based movements, the mean of a ProMP is given by a weighted sum of $N$ Gaussians. These Gaussian basis functions have the following form:

\begin{equation}
b_i(t):=\mathrm{exp}\left(-\frac{(t-c_i)^2}{2h}\right),\;i=1,\ldots,N
\tag{1}
\end{equation}

These functions are commonly normalized to improve regression

\begin{equation}
\phi_i(t):=\frac{b_i(t)}{\sum_{j=1}^Nb_j(t)}
 \tag{2}
\end{equation}

The function $z(t)$ allows temporal modulation, so that our basis functions are given by:

\begin{equation}
\phi_i(z)=\phi_i(z(t))
 \tag{3}
\end{equation}

We work in discrete time, so we define a time step d$t$. By default, we work with a time lapse of 1s, therefore, if $T$ steps are needed, d$t=1/T$. We then introduce $\mathbf{\Phi}_t:=(\phi_1(t),\ldots,\phi_N(t))\in R^{N\times 1}$, which includes the evaluation of all basis functions in a time step.

The **BasisFuncGauss** function creates and evaluates $N$ basis Gaussian functions with a bandwidth $h$. Input $f$ refers to the linear modulation factor, so that the modulation is given by $z(t):=ft$. Then, the Gaussian functions $\phi_i(t)$ are evaluated as $\phi_i(ft)$. $dt$ represents the time step. The functions have modes at $c_i$, evenly spread along $[0,1/f]$. The output of this function is a $T\times N$array for which every row corresponds to the vector $\mathbf{\Phi}_t$ for every time step.

In the code cell below, write instructions that will normalize the basis functions in $F$ in line 19 (use eq(2)).

```python
def BasisFuncGauss(N, h, f, dt):
    """
    Evaluates Gaussian basis functions in [0,1/f]
    N = number of basis functions
    h = bandwidth
    dt = time step
    f = modulation factor
    """
    tf = 1/f;
    T = int(round(tf/dt+1))
    Phi = np.zeros((T,N))
    for z in range(0,T):
        t = z*dt
        phi = np.zeros((1, N))
        for k in range(1,N+1):
            c = (k-1)/(N-1)
            phi[0,k-1] = np.exp(-(f*t - c)*(f*t - c)/(2*h))
        Phi[z,:N] = phi[0, :N]
    Phi = Phi/np.transpose(mat.repmat(np.sum(Phi,axis=1),N,1)); #[TxN]
    return Phi #[TxN]

```

In a ProMP, at time step $t$, a joint variable $q$ is modeled as:

\begin{equation}
q_t = \mathbf{\Phi}_t^{\mathrm{T}}\boldsymbol w + \epsilon_q
 \tag{4}
\end{equation}

where $\epsilon_q$ adds zero-mean Gaussian observation noise with variance $\Sigma_q$. It follows that the probability of observing $q_t$ is represented by:

\begin{equation}
p(q_t|\boldsymbol w)=\mathcal{N}\left(q_t\,\big|\,\mathbf{\Phi}^{\mathrm{T}}_t\boldsymbol w, \mathbf{\Sigma}_q\right)
 \tag{5}
\end{equation}

Since $\Sigma_q$ is the same for every time step, the values $q_t$ are taken from independent and identical distributions, i.i.d. Hence, the probability of observing a trajectory $\tau:=\{q_1,\ldots,q_T\}$ is given by:

\begin{equation}
p(q_t|\boldsymbol w):=\prod_{t=1}^Tp(q_t|\boldsymbol w)
 \tag{6}
\end{equation}

However, since parameters $\boldsymbol w$ are to be learnt from data, we also assume such parameters are taken from a distribution $\boldsymbol w\sim p(\boldsymbol w|\theta)=\mathcal{N}(\boldsymbol w|\mu_{\boldsymbol w},\mathbf{\Sigma}_{\boldsymbol w})$. We therefore would like to have a predictive distribution of $q_t$ which does not depend on $\boldsymbol w$, but on $\theta:=(\mu_{\boldsymbol w},\mathbf{\Sigma}_{\boldsymbol w})$. This is done buy marginalizing $\boldsymbol w$ out in the distribution as follows:

\begin{equation}
p(q_t|\theta) = \int\mathcal{N}(q_t\,\big|\,\mathbf{\Phi}^{\mathrm{T}}_t\boldsymbol w,\,\Sigma_q)\mathcal{N}(\boldsymbol w\,\big|\,\mu_{\boldsymbol w},\,\Sigma_{\boldsymbol w})\mathrm{d}\boldsymbol w
 = \mathcal{N}(q_t\,\big|\,\mathbf{\Phi}^{\mathrm{T}}_t\boldsymbol w,\,\Sigma_q+\mathbf{\Phi}^{\mathrm{T}}_t\mathbf{\Sigma}_{\boldsymbol w}\mathbf{\Phi}_t)
  \tag{7}
\end{equation}

In the *ProMP* class, a *ProMP* object is initialized by the number of basis functions $N$, the bandwidth in the basis functions $h$, the time step d$t$, the covariance of the original distributions on $q$, *covQ* ($\Sigma_q$), the mean of the weights, $Wm$ ($\mu_{\boldsymbol w}$), and the covariance of the weights, *covW* ($\mathbf{\Sigma}_{\boldsymbol w}$).

When initialized, the class also defines other convenience variables. *Phi* is the $T\times N$ matrix containing the basis functions evaluated for every time step and with a default modulation factor equal to 1; $Qm$ ($\mu_{q,t}$) is the mean values of $q$ for every time step; and *cov* is the covariance of the marginal distribution on $q$ for every time step.

In the code cell below, write instructions that will calculate the covariance matrix $cov$  in line 13 (use eq(7)).

```python
def __init__(self, N, h, dt, covQ, Wm, covW):
    self.N = N
    self.h = h
    self.dt = dt
    self.covQ = covQ
    self.Wm = Wm #[Nx1]
    self.covW = covW #[NxN]
    self.Phi = BasisFuncGauss(N,h,1,dt) #[TxN]
    self.T,_ = self.Phi.shape
    self.Qm = np.matmul(self.Phi,self.Wm) #[Tx1]
    self.cov = np.zeros((self.T,1)) #[Tx1]
    for i in range(0,self.T):
        self.cov[i,0] = self.covQ + np.matmul(np.array([self.Phi[i,:]]),np.matmul(covW,np.transpose(np.array([self.Phi[i,:]]))))
```

One of the big advantages of the ProMPs approach is the inclusion of new via-points. Say we wish to add a new via-point $q_{t^{*}}^{*}$ at time $t^{*}$ which is to be observed with an uncertainty of $\Sigma_{q^{*}}$. We simply have to obtain a posterior distribution over $\boldsymbol w$ by affecting our prior distribution with the new information given by $q_{t^{*}}^{*}$ and $\Sigma_{q^{*}}$. We thus apply Bayes theorem as follows:

\begin{equation}
p(\boldsymbol w|q_{t^{*}}^{*},\Sigma_{q^{*}})\propto\mathcal{N}\left(q_{t^{*}}^{*}\,\big|\,\mathbf{\Phi}^{\mathrm{T}}_{t^{*}}\boldsymbol w,\,\Sigma_{q^{*}}\right)p(\boldsymbol w)
 \tag{8}
\end{equation}

The result of applying the Bayes theorem leads to the following mean and variance of the posterior distribution:

\begin{equation}
\mu_{\boldsymbol w}^{[\mathrm{new}]} = \mu_{\boldsymbol w}+\mathbf{\Sigma}_{\boldsymbol w}\mathbf{\Phi}_{t^{*}}(\Sigma_q^{*}+\mathbf{\Phi}_{t^{*}}^{\mathrm{T}}\mathbf{\Sigma}_{\boldsymbol w}\mathbf{\Phi}_{t^{*}})^{-1}(q_{t^{*}}^{*}-\mathbf{\Phi}^{\mathrm{T}}_{t^{*}}\mu_{\boldsymbol w})
 \tag{9}
\end{equation}

\begin{equation}
\mathbf{\Sigma}_{\boldsymbol w}^{[\mathrm{new}]} = \mathbf{\Sigma}_{\boldsymbol w}-\mathbf{\Sigma}_{\boldsymbol w}\mathbf{\Phi}_{t^{*}}(\Sigma_q^{*}+\mathbf{\Phi}_{t^{*}}^{\mathrm{T}}\mathbf{\Sigma}_{\boldsymbol w}\mathbf{\Phi}_{t^{*}})^{-1}\mathbf{\Phi}_{t^{*}}^{\mathrm{T}}\mathbf{\Sigma}_{\boldsymbol w}
\end{equation}

The *condition* method conditions a ProMP to the new observation *Qstar* ($q^{*}_{t^{*}}$) at time *tstar* ($t^{*}$) with precision of observation *covQstar* ($\Sigma_{q^{*}}$).

Complete the below code cell to compute *Wm* and *covW* follwing Eq. (9). Use variable *Phit* ($\Phi_{t^{*}}$) which is defined inside the function..

```python
 def condition(self, tstar, Qstar, covQstar):
    Phit = np.transpose(self.Phi[tstar-1:tstar,:])
    self.Wm = self.Wm + np.matmul(self.covW,Phit)*1/(covQstar+np.matmul(np.transpose(Phit),np.matmul(self.covW,Phit)))*(Qstar-np.matmul(np.transpose(Phit),self.Wm))
    self.covW = self.covW - np.matmul(self.covW,Phit)*1/(covQstar+np.matmul(np.transpose(Phit),np.matmul(self.covW,Phit)))*np.matmul(np.transpose(Phit),self.covW)
    self.Qm = np.matmul(self.Phi,self.Wm)
    for i in range(0,self.T):
        self.cov[i,0] = self.covQ + np.matmul(np.array([self.Phi[i,:]]),np.matmul(self.covW,np.transpose(np.array([self.Phi[i,:]]))))
```

Temporal modulation is carried out by means of the previously defined phase function $z(t)$.

Member function *modulate* allows temporal modulation of a ProMP. Only linear time modulation is supported by this class. Modulation is given by the *factor* ($f$) input variable which is originally set to 1 when a ProMP is initialized. The phase function is then given by $z(t)=ft$. Note that $dt$ is constant throughout the code, hence, temporal modulation modifies $T$.

```python
 def modulate(self, factor):
    self.Phi = BasisFuncGauss(self.N,self.h,factor,self.dt) #[TxN]
    self.T,_ = self.Phi.shape # new T
    self.Qm = np.matmul(self.Phi,self.Wm) #[Tx1]
    self.cov = np.zeros((self.T,1)) #[Tx1]
    for i in range(0,self.T):
        self.cov[i,0] = self.covQ + np.matmul(np.array([self.Phi[i,:]]),np.matmul(self.covW,np.transpose(np.array([self.Phi[i,:]]))))
```

The *printMP* method inside the *ProMP* class plots the mean *Qm* ($\mu_q$) against time. It also plots two standard deviations above and below $\mu_q$ in order to show the marginal distribution at every time step. The *name* value refers to the title of the plot.

To complete the code cell bellow, compute the $T\times 1$ arrays *upper* and *lower* which contain two standard deviations above and below the mean $\mu_q$, respectively, for every time step. Remember that we store the covariance at every time step in the member variable *cov*.

```python
def printMP(self, name):
    t = np.arange(0, self.T*self.dt, self.dt)
    upper = self.Qm + 2*np.sqrt(self.cov)
    lower = self.Qm - 2*np.sqrt(self.cov)
    plt.plot(t,self.Qm)
    plt.fill_between(t, upper[:,0], lower[:,0], color = 'k', alpha = 0.1)
    plt.title(name)
    plt.show()
```

We would like to be able to blend different MPs into a single movement. For example, given two ProMPs, each having an important via-point, we wish to blend them so that our new ProMP crosses both via-points. We can do this by activation and deactivation of the ProMPs. We define the activation functions $\alpha^{[1]}(t)$ and $\alpha^{[2]}(t)$, with values $\alpha_{t}^{[1]}, \alpha_{t}^{[2]}\in[0,1]$ at each time step $t=1,\ldots,T$. When the value of an activation function reaches 0, the corresponding ProMP is fully deactivated. Oppositely, when the value reaches 1, the ProMP is fully activated. With these tools, the result of blending two ProMPs with predictive distributions $p^{[i]}(q_i)=\mathcal{N}(q_t\,|\,\mu_{q,t}^{[i]},\Sigma_t^{[i]})$, $i=1,2$ yields to a new ProMP described by $p^{*}(q_t)=\mathcal{N}(q_t\,|\,\mu_{q,t}^{*},\Sigma_t^{*})$, where:

\begin{equation}
\Sigma_t^{*} = (\frac{\alpha_t^{[1]}}{\Sigma_t^{[1]}} +\frac{\alpha_t^{[2]}}{\Sigma_t^{[2]}})^{-1}
 \tag{10}
\end{equation}

\begin{equation}
\mu_{q,t}^{*} = \Sigma_t^{*}(\frac{\alpha_t^{[1]}}{\Sigma_t^{[1]}}\mu_{q,t}^{[1]} + \frac{\alpha_t^{[2]}}{\Sigma_t^{[2]}}\mu_{q,t}^{[2]})
\end{equation}

The **blend** function blends two **ProMPs** objects, **MP1** and **MP2**, according to the activation functions **alpha1** ($\alpha_t^{[1]}$) and **alpha2** ($\alpha_t^{[2]}$). Vectors **alpha1** and **alpha2** contain the values of the activation functions for every time step. It is assumed that both ProMPs have the same value for **T**, hence, **alpha1** and **alpha2** must be arrays of **T** elements.

Using Eq. (10), complete the code above by writing the expressions for $cov12$ and $Qm12$, which correspond to the covariance $\Sigma_t^{*}$ and mean $\mu_{q,t}^{*}$ of the resulting ProMP at every time step. In order to be compatible with the $ProMP$ class,$cov12$ and $Qm12$ must be arrays with shapes $(T,1)$.

```python
def blend(MP1,MP2,alpha1,alpha2):
    """
    blends two MPs
        MP1, MP2 = ProMP objects to blend
        alpha1, alpha2 = activation functions for each respective MP [Tx1]
    """
    a1 = np.transpose(np.array([alpha1])) #[Tx1]
    a2 = np.transpose(np.array([alpha2])) #[Tx1]
    cov12 = (a1*(MP1.cov**-1) + a2*(MP2.cov**-1))**-1
    Qm12 = (cov12)*(a1*(MP1.cov**-1)*MP1.Qm + a2*(MP2.cov**-1)*MP2.Qm)
    M12 = ProMP(MP1.N,MP1.h,MP1.dt,MP1.covQ,np.zeros((MP1.N,1)),np.zeros((MP1.N,MP1.N)))
    M12.cov = cov12
    M12.Qm = Qm12
    return M12
```

Now that we defined the methods we can define ProMP class. Replace your written method in the corresponding section.

```python
class ProMP:
    """
    ProMP class
        N = number of basis functions
        h = bandwidth of basis functions
        dt = time step
        covQ = variance of original p(Q)
        Wm = mean of weights [Nx1]
        covW = variance of weights [NxN]
    internal:
        Phi = basis functions evaluated for every step [TxN]
        Qm = mean of Q [Nx1]
        cov = variance of p(Q|Wm) for every step  [Tx1]
    methods:
        condition (conditions an MP for a new viapoint)
            tstar = step of viapoint
            Qstar = value of Q of the viapoint
            covTstar = uncertainty of observation
        modulate (linear time modulation of the MP)
            factor = factor of linear modulation, z = factor*t
        printMP (plots an MP showing a standar deviation above and below)
            name = title of the plot
    """
    def __init__(self, N, h, dt, covQ, Wm, covW):
        self.N = N
        self.h = h
        self.dt = dt
        self.covQ = covQ
        self.Wm = Wm #[Nx1]
        self.covW = covW #[NxN]
        self.Phi = BasisFuncGauss(N,h,1,dt) #[TxN]
        self.T,_ = self.Phi.shape
        self.Qm = np.matmul(self.Phi,self.Wm) #[Tx1]
        self.cov = np.zeros((self.T,1)) #[Tx1]
        for i in range(0,self.T):
            self.cov[i,0] = self.covQ + np.matmul(np.array([self.Phi[i,:]]),np.matmul(covW,np.transpose(np.array([self.Phi[i,:]]))))
    def condition(self, tstar, Qstar, covQstar):
        Phit = np.transpose(self.Phi[tstar-1:tstar,:])
        self.Wm = self.Wm + np.matmul(self.covW,Phit)*1/(covQstar+np.matmul(np.transpose(Phit),np.matmul(self.covW,Phit)))*(Qstar-np.matmul(np.transpose(Phit),self.Wm))
        self.covW = self.covW - np.matmul(self.covW,Phit)*1/(covQstar+np.matmul(np.transpose(Phit),np.matmul(self.covW,Phit)))*np.matmul(np.transpose(Phit),self.covW)
        self.Qm = np.matmul(self.Phi,self.Wm)
        for i in range(0,self.T):
            self.cov[i,0] = self.covQ + np.matmul(np.array([self.Phi[i,:]]),np.matmul(self.covW,np.transpose(np.array([self.Phi[i,:]]))))
    def modulate(self, factor):
        self.Phi = BasisFuncGauss(self.N,self.h,factor,self.dt) #[TxN]
        self.T,_ = self.Phi.shape # new T
        self.Qm = np.matmul(self.Phi,self.Wm) #[Tx1]
        self.cov = np.zeros((self.T,1)) #[Tx1]
        for i in range(0,self.T):
            self.cov[i,0] = self.covQ + np.matmul(np.array([self.Phi[i,:]]),np.matmul(self.covW,np.transpose(np.array([self.Phi[i,:]]))))
    def printMP(self, name):
        t = np.arange(0, self.T*self.dt, self.dt)
        upper = self.Qm + 2*np.sqrt(self.cov)
        lower = self.Qm - 2*np.sqrt(self.cov)
        plt.plot(t,self.Qm)
        plt.fill_between(t, upper[:,0], lower[:,0], color = 'k', alpha = 0.1)
        plt.title(name)
        plt.show()
```

Now we will use the samples of weights $\boldsymbol w$ obtained in 5 observations. So we assume that the learning process has been completed. With this data, a ProMP for a single joint variable $q$ is created. We will carry out the following experiments:

*   Condition the ProMP to a first via-point
*   Condition the resulting ProMP from the previous experiment to a second via-point

*   Obtain two ProMPs after conditioning the original MP to each via-point, separately, and blend the two results
*   Time-modulate the original ProMP to different phases

Array $Wsamples$ includes the values of the $N = 15$ weights after 5 observations. $\mu_{\boldsymbol w}$ is computed from these observations and stored in $Wmean$. Similarly, $\mathbf{\Sigma}_{\boldsymbol w}$ is saved in $Wcov$. Using these values, three identical objects of the class $ProMP$ are defined.

```python
 # 15 weights obtained from 5 observations
Wsamples = np.array([[0.0141,0.0130,0.0038,0.0029,0.0143],
                      [0.0044,0.2025,0.0178,0.0703,0.0143],
                      [0.0388,0.1042,0.0531,0.0854,0.1479],
                      [0.0025,0.0321,0.0235,0.0495,0.0086],
                      [0.0810,0.0178,0.1500,0.0310,0.0843],
                      [0.0658,0.1258,0.0488,0.1650,0.1398],
                      [0.1059,0.0821,0.0116,0.2260,0.0531],
                      [0.0032,0.0952,0.0305,0.2220,0.0025],
                      [0.2031,0.1665,0.1430,0.0842,0.0656],
                      [0.0491,0.1543,0.1232,0.1505,0.0049],
                      [0.1914,0.0525,0.0783,0.0009,0.0292],
                      [0.0584,0.1035,0.0830,0.0305,0.1452],
                      [0.0157,0.1713,0.2550,0.0695,0.0051],
                      [0.2106,0.0630,0.0942,0.0086,0.1512],
                      [0.0959,0.2093,0.1388,0.0566,0.0819]])

Wmean = np.transpose([np.mean(Wsamples, axis=1)])
Wcov = np.cov(Wsamples)
N,_ = Wsamples.shape

T = 100
dt = 1/(T-1)

# Define MPs
MP1 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP2 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP3 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
```

$MP2$ is conditioned to point $Qstar1$, at time step $T$, with an uncertainty of the observation equal to $covQstar1$. The desired via-point, the original mean and the new conditioned mean are then plotted.

```python
# New desired point
tstar1 = 100
Qstar1 = MP2.Qm[100-1]+ 0.03
covQstar1 = 1e-6
MP2.condition(tstar1,Qstar1,covQstar1)

t = np.arange(0, 1+dt, dt)

# Plot original mean, and mean of conditioned MP
plt.figure()
plt.plot(t,MP1.Qm, 'r--', t, MP2.Qm, 'k')
plt.plot(tstar1/(MP1.T), Qstar1,'ro')
plt.title('Coditioning for point 1')
plt.show()

# Print MP conditioned to point 1
MP2.printMP('Coditioning for point 1')
```

The resulting MP is now conditioned to a second via-point, $Qstar2$, at time step $tstar2$. The resulting ProMP must cross both points.

```python
# Second desired point
tstar2 = 30
Qstar2 = 0.11
covQstar2 = 1e-6
MP2.condition(tstar2,Qstar2,covQstar2)

plt.figure()
plt.plot(t,MP1.Qm, 'r--', t, MP2.Qm, 'k')
plt.plot(tstar1/(MP1.T), Qstar1,'ro')
plt.plot(tstar2/(MP1.T), Qstar2,'ro')
plt.title('Coditioning for point 1 and point 2')
plt.show()

MP2.printMP('Coditioning for point 1 and point 2')
```

MPs $MP1$ and $MP2$ are conditioned to points 1 and 2, respectively. Then both are blended. The result of the blending must have both points as via-points.

To complete the code, come up with an expression for the $T-$element array ***alpha1*** which contains the values $\alpha_t^{[1]}$ of the activation function for MP **MP1**. Since tstar1 = 100 and tstar2 = 30, $\alpha_t^{[1]}$ must be equal (or close) to 1 at $t = 100$ but must be equal (or close) to 0 at $t = 30$, and there must be a smooth transition between these values. $\alpha_t^{[2]}$ is simply computed by \texttt{alpha2 = -alpha1+1} in the following line of the code.

For this, find suitable values for $A$, $B$ and $C$ in the following function:
\begin{equation}
\alpha^{[1]}(t):=A\mathrm{tanh}\left(B\left(\frac{t-C}{T}\right)\right)+A
\end{equation}
The figure below shows an example of such a function using $B=30$, but you can play with this value and see how it affects the result.

The program then plots the ProMPs with the following results:

```python
# Blending: MP1 is conditioned to point 1, MP2 is conditioned to point 2, MP12 is the result of blending both
MP1 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP2 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP1.condition(tstar1,Qstar1,covQstar1)
MP2.condition(tstar2,Qstar2,covQstar2)
MP1.printMP('MP1: Coditioning for point 1')
MP2.printMP('MP2: Coditioning for point 2')
# Activation functions for blending
alpha1 = 0.5*np.tanh(30*(np.arange(0,MP1.T)/(MP1.T)-65/(MP1.T)))+0.5
alpha2 = -alpha1+1
MP12 = blend(MP1,MP2,alpha1,alpha2)
MP12.printMP('blending of MP1 and MP2')
```

Given three initially identical MPs, MP1, MP2 and MP3, MP2 is modulated with a factor of 0.75, i.e. $z(t)=0.75t$, and MP3 with a factor 1.5, i.e. $z(t)=1.5t$. MP1 is left unmodified.

```python
#time modulation
MP1 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP2 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP3 = ProMP(N,0.02,dt,1e-6,Wmean,Wcov)
MP2.modulate(0.75)
MP3.modulate(1.5)

t1 = np.arange(0, MP1.T*dt, dt)
t2 = np.arange(0, MP2.T*dt, dt)
t3 = np.arange(0, MP3.T*dt, dt)

# Plot original mean, and mean of conditioned MP
plt.figure()
plt.plot(t1,MP1.Qm, 'k')
plt.plot(t2,MP2.Qm, 'b')
plt.plot(t3,MP3.Qm, 'g')
plt.title('Time modulation')
plt.show()

MP1.printMP('modulation factor = 1')
MP2.printMP('modulation factor = 0.75')
MP3.printMP('modulation factor = 1.5')
```