# Shrodinger Equation

## 1-d Shrodinger Equation
$$
\begin{equation}
-\frac{\hbar^2}{2\mu}\frac{\partial^2{\Psi(x, t)}}{\partial x^2} + U(x, t)\Psi(x, t)=i\hbar\frac{\partial\Psi(x, t)}{\partial t}
\end{equation}
$$

## 2-d Shrodinger Equation
$$
\begin{equation}
-\frac{\hbar^2}{2\mu}(\frac{\partial^2{\Psi(x, y, t)}}{\partial x^2}+\frac{\partial^2{\Psi(x, y, t)}}{\partial y^2}) + U(x, y, t)\Psi(x, y, t)=i\hbar\frac{\partial\Psi(x, y, t)}{\partial t}
\end{equation}
$$

## 3-d Shrodinger Equation
$$
\begin{equation}
-\frac{\hbar^2}{2\mu}(\frac{\partial^2{\Psi}}{\partial x^2}+\frac{\partial^2{\Psi}}{\partial y^2}+\frac{\partial^2{\Psi}}{\partial z^2}) + U(x, y, z, t)\Psi=i\hbar\frac{\partial\Psi}{\partial t}
\end{equation}
$$

## Infinite Potential Well(1d)

### potential energy
$$
\begin{equation}
    V(x) =
    \left\{
        \begin{array}{cc}
            0, & -\frac{L}{2} < x < \frac{L}{2}, \\
            \infin, & otherwise,
        \end{array}
    \right.
\end{equation}
$$
where $L$ is the length of the box, the location of the center of the box is 0 and $x$ is the position of the particle.

### Def PDE of Shrodinger Equation in a infinite potential well

$$
\begin{equation}
    \left\{
        \begin{array}{cc}
        i\hbar\frac{\partial\Psi(x, t)}{\partial t}+\frac{\hbar^2}{2}\frac{\partial^2\Psi(x, t)}{\partial x^2}=0 \\ \\
        \Psi(x, 0) = Asin(k_n(x+\frac{L}{2})) \\ \\
        \Psi(-\frac{L}{2}, t)=0 \\ \\
        \Psi(\frac{L}{2}, t)=0 \\ \\
        \end{array}
    \right.
    , -\frac{L}{2} < x < \frac{L}{2}
\end{equation}
$$
where $k_n=\frac{n\pi}{L}$, $n$ is a positive integer, and $|A|=\sqrt{\frac{2}{L}}$

### Finite Difference

$$
\begin{equation}
    \begin{array}{cc}
        \begin{split}
            \frac{\partial\Psi}{\partial t}
            &= \frac{i}{2}\frac{\partial^2\Psi}{\partial x^2} \\
            &= \frac{i}{2}\frac{\Psi_{j+1}^n-2\Psi_j^n+\Psi_{j-1}^n}{\Delta x^2}
        \end{split}
    \end{array}
\end{equation}
$$

obtain,
$$
\begin{equation}
    \frac{\Psi_j^{n+1}-\Psi_j^n}{\Delta t} = \frac{i}{2}\frac{\Psi_{j+1}^n-2\Psi_j^n+\Psi_{j-1}^n}{\Delta x^2}
\end{equation}
$$
where $\Psi_j^{n}$ is the wave function value at j-th point when time equal to $n\Delta t$

then,
$$
\begin{equation}
    \Psi_j^{n+1}
    = \Psi_j^n+\frac{i\Delta t}{2} \frac{\Psi_{j+1}^n-2\Psi_j^n+\Psi_{j-1}^n}{\Delta x^2}
\end{equation}
$$

matrix

$$
\begin{equation}
    \bm{\Psi^{n+1}} = \bm{\Psi^n} + \frac{i\Delta t}{2\Delta x^2}
    \left[
        \begin{array}{cc}
        -2&1&0&0&\cdots&0&0&0 \\
        1&-2&1&0&\cdots&0&0&0 \\
        0&1&-2&1&\cdots&0&0&0 \\
        \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
        \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
        0&0&0&0&\cdots&1&-2&1 \\
        0&0&0&0&\cdots&0&1&-2 \\
        \end{array}
    \right]
    \left[
        \begin{array}{cc}
        \Psi^n_1 \\
        \Psi^n_2 \\
        \Psi^n_3 \\
        \vdots \\
        \vdots \\
        \Psi^n_{J-1} \\
        \Psi^n_J \\
        \end{array}
    \right]
\end{equation}
$$

because $\Psi(-\frac{L}{2}, t)=0$ and $\Psi(\frac{L}{2}, t)=0$

obtain
$$
\begin{equation}
    \bm{\Psi^{n+1}} = \bm{\Psi^n} + \frac{i\Delta t}{2\Delta x^2}
    \left[
        \begin{array}{cc}
        0&0&0&0&\cdots&0&0&0 \\
        1&-2&1&0&\cdots&0&0&0 \\
        0&1&-2&1&\cdots&0&0&0 \\
        \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
        \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
        0&0&0&0&\cdots&1&-2&1 \\
        0&0&0&0&\cdots&0&0&0 \\
        \end{array}
    \right]
    \left[
        \begin{array}{cc}
        \Psi^n_1 \\
        \Psi^n_2 \\
        \Psi^n_3 \\
        \vdots \\
        \vdots \\
        \Psi^n_{J-1} \\
        \Psi^n_J \\
        \end{array}
    \right]
\end{equation}
$$

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

```python
# Finite Difference

# state num
state_num = 1
# box length
box_l = 2
# cal time
time_total = 2
# time step
delta_time = 0.00001
# space step
delta_x = 0.1
# time discrete num
time_n = int(time_total/delta_time)
# space discrete num
space_n = int(box_l/delta_x)

# result matrix space_point * time_point
phi_matrix = np.zeros((int(space_n), int(time_n))).astype(np.complex64)
# def A matrix
parm_matrix = -2*np.eye(int(space_n)) + np.eye(int(space_n), k=1) + np.eye(int(space_n), k=-1) + 0.j
parm_matrix[0, :] = 0
parm_matrix[-1, :] = 0

# init wave
phi_matrix[:, 0] = np.sin((state_num*np.pi/box_l)*(np.linspace(-box_l/2, box_l/2, space_n)+box_l/2))
```

```python
# iter
constant_ = 1.j*delta_time/(2*np.power(delta_x, 2))
for i in range(time_n-1):
    temp_value = phi_matrix[:, i] + constant_ * np.matmul(parm_matrix, phi_matrix[:, i])
    phi_matrix[:, i+1] = temp_value
print("done...")
```

    done...

```python
# plot
plt.figure(figsize=(10, 10), dpi=150)
time_list = np.linspace(0, time_total, time_n)
position_list = np.linspace(-box_l/2, box_l/2, space_n)

position_labels = np.around(np.linspace(-box_l/2, box_l/2, 4), 1)
# the index position of the tick labels
position_ticks = list()
for label in position_labels:
    idx_pos = len(position_list) - np.argmin(np.abs(label-position_list))
    position_ticks.append(idx_pos)

time_labels = np.around(np.linspace(0, time_total, 4), 1)
time_ticks = list()
for label in time_labels:
    idx_pos = np.argmin(np.abs(label-time_list))
    time_ticks.append(idx_pos)

# real
plt.subplot(2, 1, 1)
ax = sns.heatmap(np.real(phi_matrix), annot=False)
ax.set_xlabel("time")
ax.set_ylabel("position")
ax.set_yticks(position_ticks)
ax.set_xticks(time_ticks)
ax.set_title("real part of wave function —— time")
ax.set_xticklabels(time_labels)
ax.set_yticklabels(position_labels)

# imag
plt.subplot(2, 1, 2)
ax_imag = sns.heatmap(np.imag(phi_matrix), annot=False)
ax_imag.set_xlabel("time")
ax_imag.set_ylabel("position")
ax_imag.set_yticks(position_ticks)
ax_imag.set_xticks(time_ticks)
ax_imag.set_title("imaginary part of wave function —— time")
ax_imag.set_xticklabels(time_labels)
ax_imag.set_yticklabels(position_labels)

plt.show()
```

## Finite Difference Runge-Kutta Method

Obviously, the accuracy of finite difference is not enough, and consider to use Runge-Kutta method

### Fourth Runge-Kutta Method

$$
\begin{equation}
    \left\{
        \begin{array}{cc}
            y^{n+1} = y^n + \frac{h}{6}(k_1+2k_2+2k_3+k_4) \\ \\
            k_1 = f(y^n, t^n) \\ \\
            k_2 = f(y^n+k_1\frac{h}{2}, t^n+\frac{h}{2}) \\ \\
            k_3 = f(y^n+k_2\frac{h}{2}, t^n+\frac{h}{2}) \\ \\
            k_4 = f(y^n+hk_3, t^n+h)
        \end{array}
    \right.
\end{equation}
$$

### Finite difference with Fourth Runge-Kutta Method

$$
\begin{equation}
    \begin{split}
        \frac{\partial\Psi}{\partial t}
        &= \frac{i}{2}\frac{\Psi_{j+1}^n-2\Psi_j^n+\Psi_{j-1}^n}{\Delta x^2} \\
        &= f(\Psi, t)
    \end{split}
\end{equation}
$$

obtain
$$
\begin{equation}
    \left\{
        \begin{array}{cc}
            \begin{split}
            \Psi^{n+1}_j &= \Psi^n_j + \Delta t k \\ &= \Psi^n_j + \frac{h}{6}(k_1+2k_2+2k_3+k_4)
            \end{split}
            \\ \\
            h = \Delta t
            \\ \\
            \begin{split}
            k_{1j} &= f(\Psi^n_j, t^n)\\ &= \frac{i}{2}\frac{\Psi_{j+1}^n-2\Psi_j^n+\Psi_{j-1}^n}{\Delta x^2}
            \end{split}
            \\ \\
            \begin{split}
            k_{2j} &= f(\Psi^n_j+k_{1j}\frac{h}{2}, t^n+\frac{h}{2})\\ &= \frac{i}{2}\frac{\Psi_{j+1+k_1\frac{h}{2}}^{n+\frac{h}{2}}-2\Psi_{j+k_1\frac{h}{2}}^{n+\frac{h}{2}}+\Psi_{j-1+k_1\frac{h}{2}}^{n+\frac{h}{2}}}{\Delta x^2}\\ &= \frac{i}{2}\frac{\Psi_{j+1}^{n}+\frac{h}{2}k_{1(j+1)}-2\Psi_{j}^{n}-hk_{1j}+\Psi_{j-1}^{n}+\frac{h}{2}k_{1(j-1)}}{\Delta x^2}
            \end{split}
            \\ \\
            \begin{split}
            k_{3j} &= f(\Psi^n_j+k_{2j}\frac{h}{2}, t^n+\frac{h}{2})\\ &= \frac{i}{2}\frac{\Psi_{j+1+k_2\frac{h}{2}}^{n+\frac{h}{2}}-2\Psi_{j+k_2\frac{h}{2}}^{n+\frac{h}{2}}+\Psi_{j-1+k_2\frac{h}{2}}^{n+\frac{h}{2}}}{\Delta x^2}\\ &= \frac{i}{2}\frac{\Psi_{j+1}^{n}+\frac{h}{2}k_{2(j+1)}-2\Psi_{j}^{n}-hk_{2j}+\Psi_{j-1}^{n}+\frac{h}{2}k_{2(j-1)}}{\Delta x^2}
            \end{split}
            \\ \\
            \begin{split}
            k_{4j} &= f(\Psi^n_j+hk_{3j}, t^n+h)\\ &= \frac{i}{2}\frac{\Psi_{j+1+hk_3}^{n+h}-2\Psi_{j+hk_3}^{n+h}+\Psi_{j-1+hk_3}^{n+h}}{\Delta x^2}\\ &= \frac{i}{2}\frac{\Psi_{j+1}^{n}+hk_{3(j+1)}-2\Psi_{j}^{n}-2hk_{3j}+\Psi_{j-1}^{n}+hk_{3(j-1)}}{\Delta x^2}
            \end{split}
        \end{array}
    \right.
\end{equation}
$$

matrix
$$
\begin{equation}
    \begin{array}{cc}
        \begin{split}
            \bm{k_1^n}
            &=
            \left[
                \begin{array}{cc}
                    f(\Psi^n_1, t^n) \\
                    f(\Psi^n_2, t^n) \\
                    \vdots \\
                    f(\Psi^n_J, t^n) \\
                \end{array}
            \right] \\
            &=
            \frac{i}{2\Delta x^2}
            \left[
                \begin{array}{cc}
                -2&1&0&0&\cdots&0&0&0 \\
                1&-2&1&0&\cdots&0&0&0 \\
                0&1&-2&1&\cdots&0&0&0 \\
                \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                0&0&0&0&\cdots&1&-2&1 \\
                0&0&0&0&\cdots&0&1&-2 \\
                \end{array}
            \right]
            \left[
                \begin{array}{cc}
                \Psi^n_1 \\
                \Psi^n_2 \\
                \Psi^n_3 \\
                \vdots \\
                \vdots \\
                \Psi^n_{J-1} \\
                \Psi^n_J \\
                \end{array}
            \right]
        \end{split}
    \end{array}
\end{equation}
$$

$$
\begin{equation}
    \begin{split}
            \bm{k_2^n}
            &=
            \left[
                \begin{array}{cc}
                f(\Psi^n_1+k_1\frac{h}{2}, t^n+\frac{h}{2}) \\
                f(\Psi^n_2+k_1\frac{h}{2}, t^n+\frac{h}{2}) \\
                \vdots \\
                f(\Psi^n_J+k_1\frac{h}{2}, t^n+\frac{h}{2}) \\
                \end{array}
            \right] \\
            &=
            \frac{i}{2\Delta x^2}
                \left[
                    \begin{array}{cc}
                    -2&1&0&0&\cdots&0&0&0 \\
                    1&-2&1&0&\cdots&0&0&0 \\
                    0&1&-2&1&\cdots&0&0&0 \\
                    \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                    \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                    0&0&0&0&\cdots&1&-2&1 \\
                    0&0&0&0&\cdots&0&1&-2 \\
                    \end{array}
                \right]
                \left(
                \left[
                    \begin{array}{cc}
                    \Psi^n_1 \\
                    \Psi^n_2 \\
                    \Psi^n_3 \\
                    \vdots \\
                    \vdots \\
                    \Psi^n_{J-1} \\
                    \Psi^n_J \\
                    \end{array}
                \right] + \frac{h}{2}\bm{k_1^n}
                \right)
        \end{split}
\end{equation}
$$

$$
\begin{equation}
    \begin{split}
            \bm{k_3^n}
            &=
            \left[
                \begin{array}{cc}
                f(\Psi^n_1+k_2\frac{h}{2}, t^n+\frac{h}{2}) \\
                f(\Psi^n_2+k_2\frac{h}{2}, t^n+\frac{h}{2}) \\
                \vdots \\
                f(\Psi^n_J+k_2\frac{h}{2}, t^n+\frac{h}{2}) \\
                \end{array}
            \right] \\
            &=
            \frac{i}{2\Delta x^2}
                \left[
                    \begin{array}{cc}
                    -2&1&0&0&\cdots&0&0&0 \\
                    1&-2&1&0&\cdots&0&0&0 \\
                    0&1&-2&1&\cdots&0&0&0 \\
                    \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                    \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                    0&0&0&0&\cdots&1&-2&1 \\
                    0&0&0&0&\cdots&0&1&-2 \\
                    \end{array}
                \right]
                \left(
                \left[
                    \begin{array}{cc}
                    \Psi^n_1 \\
                    \Psi^n_2 \\
                    \Psi^n_3 \\
                    \vdots \\
                    \vdots \\
                    \Psi^n_{J-1} \\
                    \Psi^n_J \\
                    \end{array}
                \right] + \frac{h}{2}\bm{k_2^n}
                \right)
        \end{split}
\end{equation}
$$

$$
\begin{equation}
    \begin{split}
            \bm{k_4^n}
            &=
            \left[
                \begin{array}{cc}
                f(\Psi^n_1+hk_3, t^n+h) \\
                f(\Psi^n_2+hk_3, t^n+h) \\
                \vdots \\
                f(\Psi^n_J+hk_3, t^n+h) \\
                \end{array}
            \right] \\
            &=
            \frac{i}{2\Delta x^2}
                \left[
                    \begin{array}{cc}
                    -2&1&0&0&\cdots&0&0&0 \\
                    1&-2&1&0&\cdots&0&0&0 \\
                    0&1&-2&1&\cdots&0&0&0 \\
                    \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                    \vdots&\ddots&\ddots&\ddots&\ddots&\ddots&\ddots&\vdots \\
                    0&0&0&0&\cdots&1&-2&1 \\
                    0&0&0&0&\cdots&0&1&-2 \\
                    \end{array}
                \right]
                \left(
                \left[
                    \begin{array}{cc}
                    \Psi^n_1 \\
                    \Psi^n_2 \\
                    \Psi^n_3 \\
                    \vdots \\
                    \vdots \\
                    \Psi^n_{J-1} \\
                    \Psi^n_J \\
                    \end{array}
                \right] + h\bm{k_3^n}
                \right)
        \end{split}
\end{equation}
$$

$$
\begin{equation}
    \bm{\Psi^{n+1}} = \bm{\Psi^n} + \frac{h}{6}(\bm{k_1^n}+2\bm{k_2^n}+2\bm{k_3^n}+\bm{k_4^n})
\end{equation}
$$

```python
# Finite Difference with Fourth Lunge-Kutta Method

# state num
state_num = 2
# box length
box_l = 2
# cal time
time_total = 2
# time step
delta_time = 2e-5
# space step
delta_x = 1e-2
# time discrete num
time_n = int(time_total/delta_time)
# space discrete num
space_n = int(box_l/delta_x)

# result matrix space_point * time_point
phi_matrix = np.zeros((int(space_n), int(time_n))).astype(np.complex64)

# def A matrix
parm_matrix = -2*np.eye(int(space_n)) + np.eye(int(space_n), k=1) + np.eye(int(space_n), k=-1) + 0.j
parm_matrix[0, :] = 0
parm_matrix[-1, :] = 0

# def k1, k2, k3, k4
# k1 = k_vector_matrix[:, 0]
# k2 = k_vector_matrix[:, 1]
# k3 = k_vector_matrix[:, 2]
# k4 = k_vector_matrix[:, 3]
k_vector_matrix = np.zeros((int(space_n), 4)).astype(np.complex64)

# init wave
phi_matrix[:, 0] = np.sin((state_num*np.pi/box_l)*(np.linspace(-box_l/2, box_l/2, space_n)+box_l/2))
```

```python
# iter
constant_ = 1.j/(2*np.power(delta_x, 2))
for i in range(time_n-1):
    # k1
    k_vector_matrix[:, 0] = constant_ * np.matmul(parm_matrix, phi_matrix[:, i])
    # k2
    k_vector_matrix[:, 1] = constant_ * (np.matmul(parm_matrix, phi_matrix[:, i]+(delta_time/2)*k_vector_matrix[:, 0]))
    # k3
    k_vector_matrix[:, 2] = constant_ * (np.matmul(parm_matrix, phi_matrix[:, i]+(delta_time/2)*k_vector_matrix[:, 1]))
    # k4
    k_vector_matrix[:, 3] = constant_ * (np.matmul(parm_matrix, phi_matrix[:, i]+(delta_time)*k_vector_matrix[:, 2]))
    # if i % 1000 == 0:
    #     print(np.max(k_vector_matrix))
    phi_matrix[:, i+1] = phi_matrix[:, i] + (delta_time/6)*(k_vector_matrix[:, 0] + 2*k_vector_matrix[:, 1] + 2*k_vector_matrix[:, 2] + k_vector_matrix[:, 3])
print("done...")
```

    done...

```python
# plot

plt.figure(figsize=(10, 10), dpi=150)

draw_time_list = np.linspace(0, time_n-1, min(400, time_n)).astype(np.int32)
draw_position_list = np.linspace(0, space_n-1, min(400, space_n)).astype(np.int32)
phi_matrix_draw = phi_matrix[draw_position_list, :][:, draw_time_list]

time_list = np.linspace(0, time_total, len(draw_time_list))
position_list = np.linspace(-box_l/2, box_l/2, len(draw_position_list))

position_labels = np.around(np.linspace(-box_l/2, box_l/2, 4), 1)
# the index position of the tick labels
position_ticks = list()
for label in position_labels:
    idx_pos = len(position_list) - np.argmin(np.abs(label-position_list))
    position_ticks.append(idx_pos)

time_labels = np.around(np.linspace(0, time_total, 4), 1)
time_ticks = list()
for label in time_labels:
    idx_pos = np.argmin(np.abs(label-time_list))
    time_ticks.append(idx_pos)

# real
plt.subplot(2, 1, 1)
ax = sns.heatmap(np.real(phi_matrix_draw), annot=False)
ax.set_xlabel("time")
ax.set_ylabel("position")
ax.set_yticks(position_ticks)
ax.set_xticks(time_ticks)
ax.set_title("real part of wave function —— time")
ax.set_xticklabels(time_labels)
ax.set_yticklabels(position_labels)

# imag
plt.subplot(2, 1, 2)
ax_imag = sns.heatmap(np.imag(phi_matrix_draw), annot=False)
ax_imag.set_xlabel("time")
ax_imag.set_ylabel("position")
ax_imag.set_yticks(position_ticks)
ax_imag.set_xticks(time_ticks)
ax_imag.set_title("imaginary part of wave function —— time")
ax_imag.set_xticklabels(time_labels)
ax_imag.set_yticklabels(position_labels)

plt.show()
```

## PINN
Using PINN to solve the Shrodinger Equation in a infinite potential well

### model

* #### Inputs
position $x_i$, and time $t_i$
* #### output
the wave function value $u$ at $(x_i, t_i)$
* #### Conditions
    * **PDE**
    $$
    \begin{equation}
    f(x, t) = i \frac{\partial{u}}{\partial{t}} + 2\frac{\partial^2{u}}{\partial{x}^2} = 0, \\ -\frac{L}{2}\leq x \leq \frac{L}{2}
    \end{equation}
    $$
    * **Boundary Conditions**
    $$
    \begin{equation}
        \begin{array}{cc}
            u(-\frac{L}{2}, t)=0 \\
            u(\frac{L}{2}, t)=0
        \end{array}
    \end{equation}
    $$
    * **Initial Conditions**
    $$
    \begin{equation}
        \begin{array}{cc}
            u(x, 0) = Asin(k_n(x+\frac{L}{2})) \\
            k_n = \frac{n\pi}{L} \\
            |A|=\sqrt{\frac{2}{L}}
        \end{array}
    \end{equation}
    $$
* #### Loss Function
$$
\begin{equation}
    \begin{array}{cc}
        \mathcal{L}=MSE_b+MSE_0+MSE_f, \\ \\
        MSE_b = \frac{1}{|N_b|}\sum_{i=1}^{|N_b|}(|u(-\frac{L}{2}, t^i_b)|^2+|u(\frac{L}{2}, t^i_b)|^2), \\ \\
        MSE_0 = \frac{1}{|N_0|}\sum_{i=1}^{|N_0|} |u(x_0^i, 0)-u_0^i|^2, \\ \\
        MSE_f = \frac{1}{|N_f|}\sum_{i=1}^{|N_f|} |f(x_f^i, t_f^i)|^2
    \end{array}
\end{equation}
$$
where
$N_f = \{ (x_f^1, t_f^1), (x_f^2, t_f^2), \cdots, (x_f^{|N_f|}, t_f^{|N_f|}) \}$ is the dataset to calculate the loss of PDE,
$|N_f|$ is the total number of $N_f$,
$N_0 = \{ (x_0^1, u_0^1), (x_0^2, u_0^2), \cdots, (x_0^{|N_0|}, u_0^{|N_0|}) \}$ denotes the initial data,
$|N_0|$ is the total number of $N_0$,
$N_b=\{ t^1_b, t^2_b, \cdots, t^{|N_b|}_b \}$ corresponds to the collocation points on the boundary,
and $|N_b|$ is the total number of $N_b$,

* #### Optimization Method
In order to calculate the value of $f(x_f^i, t_f^i)$, we need to obtain the value of $\frac{\partial{u}}{\partial{t}}$ and $\frac{\partial^2{u}}{\partial{x}^2}$, which can not be obtained directly.
Consider to use Automatic Differentiation(AD) to obtain these two value, which is an important part of Gradient Descent.

* #### Other Info
    * ##### Self-Supervised Learning or Supervised Learning?
    There is no need to provide train data to train PINN model, and the numerical data is only used to calculate the accuracy of PINN outputs

```python
def heatmap_draw_func(input_matrix, time_range, position_range):
    # plot

    time_n_raw = len(input_matrix[0, :])
    space_n_raw = len(input_matrix[:, 0])

    plt.figure(figsize=(10, 10), dpi=150)

    draw_time_list = np.linspace(0, time_n_raw-1, min(400, time_n_raw)).astype(np.int32)
    draw_position_list = np.linspace(0, space_n_raw-1, min(400, space_n_raw)).astype(np.int32)
    phi_matrix_draw = input_matrix[draw_position_list, :][:, draw_time_list]

    time_list = np.linspace(time_range[0], time_range[1], len(draw_time_list))
    position_list = np.linspace(position_range[0], position_range[1], len(draw_position_list))

    position_labels = np.around(np.linspace(position_range[0], position_range[1], 4), 1)
    # the index position of the tick labels
    position_ticks = list()
    for label in position_labels:
        idx_pos = len(position_list) - np.argmin(np.abs(label-position_list))
        position_ticks.append(idx_pos)

    time_labels = np.around(np.linspace(time_range[0], time_range[1], 4), 1)
    time_ticks = list()
    for label in time_labels:
        idx_pos = np.argmin(np.abs(label-time_list))
        time_ticks.append(idx_pos)

    # real
    plt.subplot(2, 1, 1)
    ax = sns.heatmap(np.real(phi_matrix_draw), annot=False)
    ax.set_xlabel("time")
    ax.set_ylabel("position")
    ax.set_yticks(position_ticks)
    ax.set_xticks(time_ticks)
    ax.set_title("real part of wave function —— time")
    ax.set_xticklabels(time_labels)
    ax.set_yticklabels(position_labels)

    # imag
    plt.subplot(2, 1, 2)
    ax_imag = sns.heatmap(np.imag(phi_matrix_draw), annot=False)
    ax_imag.set_xlabel("time")
    ax_imag.set_ylabel("position")
    ax_imag.set_yticks(position_ticks)
    ax_imag.set_xticks(time_ticks)
    ax_imag.set_title("imaginary part of wave function —— time")
    ax_imag.set_xticklabels(time_labels)
    ax_imag.set_yticklabels(position_labels)

    plt.show()
```

```python
# PINN

# data
# boundary_data, initial_data, f_data
# obtain from numerical method

# boundary data number
boundary_data_num = 50
# initial data number
initial_data_num = 50
# f data number
f_data_num = 2000000

# total num
total_initial_num = len(phi_matrix[:, 0])
total_boundary_num = len(phi_matrix[0, :]) * 2
total_f_num = np.prod(np.shape(phi_matrix))

# x and t data
time_list = np.linspace(0, time_total, len(phi_matrix[0, :]))
position_list = np.linspace(-box_l/2, box_l/2, len(phi_matrix[:, 0]))

# obtain the index of test data
import random
np.random.seed(1024)
random.seed(1024)

initial_data_index_list = np.random.choice(range(0, total_initial_num), initial_data_num, replace=False)
boundary_data_index_list = np.random.choice(range(0, total_boundary_num), boundary_data_num, replace=False)
f_data_index_list = np.random.choice(range(0, total_f_num), f_data_num, replace=False)

# obtain data
# (x, t, u)
initial_position = position_list[initial_data_index_list]
initial_time = np.zeros_like(initial_data_index_list)
initial_data = np.array(list(zip(initial_position, initial_time, phi_matrix[initial_data_index_list, 0])), dtype=np.complex64)

# boundary
boundary_position_loc_list = (boundary_data_index_list//len(time_list))*-1
boundary_time_loc_list = boundary_data_index_list%len(time_list)
boundary_position = position_list[boundary_position_loc_list]
boundary_time = time_list[boundary_time_loc_list]
boundary_data = np.array(list(zip(boundary_position, boundary_time, phi_matrix[boundary_position_loc_list, boundary_time_loc_list])), dtype=np.complex64)

# f
f_data_position_loc_list = f_data_index_list//len(time_list)
f_data_time_loc_list = f_data_index_list%len(time_list)
f_data_position = position_list[f_data_position_loc_list]
f_data_time = time_list[f_data_time_loc_list]
f_data = np.array(list(zip(f_data_position, f_data_time, phi_matrix[f_data_position_loc_list, f_data_time_loc_list])), dtype=np.complex64)

# draw test data
test_data_matrix = np.ones_like(phi_matrix).astype(np.complex64)*-1
test_data_matrix[initial_data_index_list, 0] = initial_data[:, 2]
test_data_matrix[boundary_position_loc_list, boundary_time_loc_list] = boundary_data[:, 2]
test_data_matrix[f_data_position_loc_list, f_data_time_loc_list] = f_data[:, 2]
heatmap_draw_func(test_data_matrix, [0, time_total], [-box_l/2, box_l/2])

f_data = f_data[:20000, :]
print("data create done...")
print("the number of initial data:{}".format(len(initial_data)))
print("the number of boundary data:{}".format(len(boundary_data)))
print("the number of f data:{}".format(len(f_data)))
```

```python
from torch.utils.data import DataLoader, Dataset
import torch
from torch import nn
from collections import OrderedDict
```

```python
# dataset and dataloader
class MyDataset(Dataset):

    def __init__(self, data_list) -> None:
        super(MyDataset, self).__init__()
        self.data_list = data_list

    def __getitem__(self, item):
        input_x = self.data_list[item][0]
        input_t = self.data_list[item][1]
        true_y_real = self.data_list[item][2]
        true_y_imag = self.data_list[item][3]

        return {"input_x": input_x, "input_t": input_t, "true_y": [true_y_real, true_y_imag]}

    def __len__(self):
        return len(self.data_list)

    @staticmethod
    def collate_fn(batch):
        input_tensor = torch.cat([torch.unsqueeze([s["input_x"], s["input_t"]], 0) for s in batch], dim=0).type(torch.float32)
        true_y = torch.as_tensor([s["true_y"] for s in batch])
        return {"input": input_tensor, "output": true_y}
```

```python
# model
# input (x, t)
# output (pred_real, pred_imag, du/dt, d^2u/dx^2)

class PINN(nn.Module):

    def __init__(self):
        super(PINN, self).__init__()

        self.pinn_network = nn.Sequential(OrderedDict([
            ("layer 1", nn.Linear(2, 100)),
            ("tanh 1", nn.Tanh()),
            ("layer 2", nn.Linear(100, 100)),
            ("tanh 2", nn.Tanh()),
            ("layer 3", nn.Linear(100, 100)),
            ("tanh 3", nn.Tanh()),
            ("layer 4", nn.Linear(100, 100)),
            ("tanh 4", nn.Tanh()),
            ("output layer", nn.Linear(100, 2)),
        ]))

    def forward(self, x):

        return self.pinn_network(x)
```

```python
bx = torch.tensor([[1., 2.], [3., 4.], [5., 6.]], requires_grad=True)
y = torch.cat((torch.unsqueeze(torch.pow(bx[:, 0], 2)+torch.pow(bx[:, 1], 2), dim=-1),
              torch.unsqueeze(torch.pow(bx[:, 0], 3)+torch.pow(bx[:, 1], 3), dim=-1)), dim=-1)
print(y)
print(y.shape, bx.shape)

dydx_1 = torch.autograd.grad(y[:, 0], bx, grad_outputs=torch.ones(bx.shape[0]), create_graph=True, retain_graph=True)
dydx_2 = torch.autograd.grad(y[:, 1], bx, grad_outputs=torch.ones(bx.shape[0]), create_graph=True, retain_graph=True)
print(torch.unsqueeze(dydx_1[0][:, 0], dim=-1).shape)
print(dydx_2)
print(torch.cat((torch.unsqueeze(dydx_1[0][:, 0], dim=-1), torch.unsqueeze(dydx_2[0][:, 0], dim=-1)), dim=1).shape)
dy_2_dx_1 = torch.autograd.grad(dydx_2[0], bx, grad_outputs=torch.ones(bx.shape), create_graph=True, retain_graph=True)
print(dy_2_dx_1)
```

    tensor([[  5.,   9.],
            [ 25.,  91.],
            [ 61., 341.]], grad_fn=<CatBackward>)
    torch.Size([3, 2]) torch.Size([3, 2])
    torch.Size([3, 1])
    (tensor([[  3.,  12.],
            [ 27.,  48.],
            [ 75., 108.]], grad_fn=<AddBackward0>),)
    torch.Size([3, 2])
    (tensor([[ 6., 12.],
            [18., 24.],
            [30., 36.]], grad_fn=<AddBackward0>),)

```python
# -------------- global params --------------
if torch.cuda.is_available():
    device = torch.device("cuda")
else:
    device = torch.device("cpu")

max_epochs = 100
recorder = dict()
recorder["acc"] = list()
recorder["loss"] = list()

# -------------- data --------------
# data set
boundary_dataset = MyDataset(boundary_data)
initial_dataset = MyDataset(initial_data)
f_dataset = MyDataset(f_data)

# data loader
boundary_dataloader = DataLoader(dataset=boundary_dataset, batch_size=boundary_data_num, shuffle=True,
                                 collate_fn=boundary_dataset.collate_fn)
initial_dataloader = DataLoader(dataset=initial_dataset, batch_size=initial_data_num, shuffle=True,
                                 collate_fn=initial_dataset.collate_fn)
f_data_dataloader = DataLoader(dataset=f_dataset, batch_size=f_data_num, shuffle=True,
                                 collate_fn=f_dataset.collate_fn)

# -------------- model --------------
pinn_model = PINN().to(device)

# -------------- loss and optimizer --------------
params = [p for p in pinn_model.parameters() if p.requires_grad]
optimizer = torch.optim.LBFGS(params)
criterion = nn.MSELoss(reduction="mean")
```

```python

# train
for epoch in range(1, max_epochs+1):
    pinn_model.train()

    optimizer.zero_grad()

    # boundary data
    for step, batch in enumerate(boundary_dataloader):
        input_ = batch["input"].to(device)
        true_output = batch["output"].to(device)

        pred_ = pinn_model(input_)
        boundary_loss = criterion(pred_, torch.zeros_like(pred_).to(device))

    # initial data
    for step, batch in enumerate(initial_dataloader):
        input_ = batch["input"].to(device)
        true_output = batch["output"].to(device)

        pred_ = pinn_model(input_)
        initial_loss = criterion(pred_, true_output)

    # f data
    for step, batch in enumerate(f_data_dataloader):
        input_ = batch["input"].to(device)

        pred_ = pinn_model(input_)

        # cal du/dt and du/dx
        # du/d(input) : (batch, input_len)
        # du/dt = du/d(input)[:, 1]
        # du/dx = du/d(input)[:, 0]
        du_dinput_real = torch.autograd.grad(pred_[:, 0], input_, grad_outputs=torch.ones(input_.shape[0]), create_graph=True, retain_graph=True)
        du_dinput_imag = torch.autograd.grad(pred_[:, 1], input_, grad_outputs=torch.ones(input_.shape[0]), create_graph=True, retain_graph=True)

        # cal d^2u/dt^2 and d^2u/dx^2
        # d^2u/d(input)^2 : (batch, input_len)
        # d^u/dt^2 : d^2u/d(input)^2[:, 1]
        # d^u/dx^2 : d^2u/d(input)^2[:, 0]
        du_dinput_real_2 = torch.autograd.grad(du_dinput_real[0], input_, grad_outputs=torch.ones(input_.shape), create_graph=True, retain_graph=True)
        du_dinput_imag_2 = torch.autograd.grad(du_dinput_imag[0], input_, grad_outputs=torch.ones(input_.shape), create_graph=True, retain_graph=True)

        # obtain du/dt
        du_dt_real = du_dinput_real[0][:, 1]
        du_dt_imag = du_dinput_imag[0][:, 1]
        # obtain d^2u/dx^2
        du_dx_2_real = du_dinput_real_2[0][:, 0]
        du_dx_2_imag = du_dinput_imag_2[0][:, 0]

        f_func_value_real = torch.unsqueeze(-du_dt_imag + 2*du_dx_2_real, dim=-1)
        f_func_value_imag = torch.unsqueeze(du_dt_real + 2*du_dx_2_imag, dim=-1)

        f_func_output = torch.cat((f_func_value_real, f_func_value_imag), dim=-1)

        f_loss = criterion(f_func_output, torch.zeros_like(f_func_output).to(device))

    total_loss = initial_loss + boundary_loss + f_loss

    total_loss.backward()

    optimizer.step()

    if epoch%10 == 0:
        print("[{}/{}]\t loss:{}".format(epoch, max_epochs, total_loss.item()))

```


```python

```

```python
import numpy as np
from sympy import *
init_printing(use_unicode=False)
L00, L11, L22, L33, L44 = symbols('L00 L11 L22 L33 L44')
L10, L20, L30, L40 = symbols('L10 L20 L30 L40')
L21, L31, L41 = symbols('L21 L31 L41')
L32, L42 = symbols('L32 L42')
L43 = symbols('L43')
D0, D1, D2, D3, D4 = symbols('D0 D1 D2 D3 D4')
```

```python
L = Matrix([[1, 0, 0, 0, 0],
            [L10, 1, 0, 0, 0],
            [L20, L21, 1, 0, 0],
            [L30, L31, L32, 1, 0],
            [L40, L41, L42, L43, 1]
])

Lt = L.transpose()

D = Matrix([
    [D0, 0, 0, 0, 0],
    [0, D1, 0, 0, 0],
    [0, 0, D2, 0, 0],
    [0, 0, 0, D3, 0],
    [0, 0, 0, 0, D4]
])
```

```python
A = L*D*Lt
```

```python
mod = A.shape[0]
mods = []
Diags = []
for b in range(mod):
    Diags.append([])
    mods.append(b)

for desloc in range(A.shape[0]):
    for i in range(A.shape[0]):
        for j in range(A.shape[0]):
            if i == j + desloc:
                Diags[desloc].append(A[i,j])
                print(A[i,j])
    print('-----------------------------')
```

    D0
    D0*L10**2 + D1
    D0*L20**2 + D1*L21**2 + D2
    D0*L30**2 + D1*L31**2 + D2*L32**2 + D3
    D0*L40**2 + D1*L41**2 + D2*L42**2 + D3*L43**2 + D4
    -----------------------------
    D0*L10
    D0*L10*L20 + D1*L21
    D0*L20*L30 + D1*L21*L31 + D2*L32
    D0*L30*L40 + D1*L31*L41 + D2*L32*L42 + D3*L43
    -----------------------------
    D0*L20
    D0*L10*L30 + D1*L31
    D0*L20*L40 + D1*L21*L41 + D2*L42
    -----------------------------
    D0*L30
    D0*L10*L40 + D1*L41
    -----------------------------
    D0*L40
    -----------------------------

```python
for elemente in A.col(1):
    print(elemente)

```

    D0*L10
    D0*L10**2 + D1
    D0*L10*L20 + D1*L21
    D0*L10*L30 + D1*L31
    D0*L10*L40 + D1*L41

```python
A00, A11, A22, A33, A44 = symbols('A00 A11 A22 A33 A44')
A10, A20, A30, A40 = symbols('A10 A20 A30 A40')
A21, A31, A41 = symbols('A21 A31 A41')
A32, A42 = symbols('A32 A42')
A43 = symbols('A43')

T = Matrix([[0, 0, 0, 0, 0],
            [A10, 0, 0, 0, 0],
            [A20, A21, 0, 0, 0],
            [A30, A31, A32, 0, 0],
            [A40, A41, A42, A43, 0]
])

T = T + T.transpose()
DT = Matrix([
    [A00, 0, 0, 0, 0],
    [0, A11, 0, 0, 0],
    [0, 0, A22, 0, 0],
    [0, 0, 0, A33, 0],
    [0, 0, 0, 0, A44]
])

T += DT
x = T - A

b0, b1, b2, b3, b4 = symbols('bo b1 b2 b3 b4')

b = Matrix([b0, b1, b2, b3, b4])

print(latex(b))
b
```

    \left[\begin{matrix}bo\\b_{1}\\b_{2}\\b_{3}\\b_{4}\end{matrix}\right]

$\displaystyle \left[\begin{matrix}bo\\b_{1}\\b_{2}\\b_{3}\\b_{4}\end{matrix}\right]$


# Introduction to the Harmonic Oscillator

*Note:* Much of this is adapted/copied from https://flothesof.github.io/harmonic-oscillator-three-methods-solution.html

This week week we are going to begin studying molecular dynamics, which uses classical mechanics to study molecular systems. Our "hydrogen atom" in this section will be the 1D harmomic oscillator.

The harmonic oscillator is a system that, when displaced from its equilibrium position, experiences a restoring force F proportional to the displacement x:

$$F=-kx$$

The potential energy of this system is

$$V = {1 \over 2}k{x^2}$$

These are sometime rewritten as

$$ F=- \omega_0^2 m x, \text{ }  V(x) = {1 \over 2}  \omega_0^2 m {x^2}$$

Where $\omega_0  = \sqrt {{k \over m}} $

In classical mechanics, our goal is to determine the equations of motion, $x(t),y(t)$, that describe our system.

In this notebook we will use sympy to solve an second order, ordinary differential equation.

## 1. Solving differential equations with sympy

Soliving differential equations can be tough, and there is not always a set plan on how to proceed. Luckily for us, the harmonic osscillator is the classic second order diffferential eqations.

Consider the following second order differential equation

$$ay(t)''+by(t)'=c$$

where $y(t)'' = {{{d^2}y} \over {dt^2}}$, and $y(t)' = {{{d}y} \over {dt}}$

We can rewrite this as a homogeneous linear differential equations

$$ay(t)''+by(t)'-c=0$$

The goal here is to find $y(t)$, similar to our classical mechanics problems. Lets use sympy to solve this equation

### Second order ordinary differential equation

First we import the sympy library

```python
import sympy as sym
```

Next we initialize pretty printing

```python
sym.init_printing()
```

Next we will set our symbols

```python
t,a,b,c=sym.symbols("t,a,b,c")
```

Now for somehting new. We can define functions using `sym.Function("f")`

```python
y=sym.Function("y")
y(t)
```

Now, If I want to define a first or second derivative, I can use `sym.diff`

```python
sym.diff(y(t),(t,1)),sym.diff(y(t),(t,2))
```

My differential equation can be written as follows

```python
dfeq=a*sym.diff(y(t),(t,2))+b*sym.diff(y(t),(t,1))-c
dfeq
```

```python
sol = sym.dsolve(dfeq)
sol
```

The two constants $C_1$ and $C_2$ can be determined by setting boundry conditions.
First, we can set the condition $y(t=0)=y_0$

The next intial condition we will set is $y'(t=0)=v_0$

To setup the equality we want to solve, we are using `sym.Eq`. This function sets up an equaility between a lhs aand rhs of an equation

```python
# sym.Eq example
alpha,beta=sym.symbols("alpha,beta")
sym.Eq(alpha+2,beta)
```

Back to the actual problem

```python
y0,v0=sym.symbols("y_0,v_0")
ics=[sym.Eq(sol.args[1].subs(t, 0), y0),
     sym.Eq(sol.args[1].diff(t).subs(t, 0), v0)]
ics
```

We can use this result to first solve for $C_2$ and then solve for $C_1$.
Or we can use sympy to solve this for us.

```python
solved_ics=sym.solve(ics)
solved_ics
```

Substitute the result back into $y(t)$

```python
full_sol = sol.subs(solved_ics[0])
full_sol
```

We can plot this result too. Assume that $a,b,c=1$ and that the starting conditions are $y_0=0,v_0=0$

We will use two sample problems:

* case 1 : initial position is nonzero and initial velocity is zero
* case 2 : initial position is zero and initialvelocity is nonzero

```python
# Print plots
%matplotlib inline
```

#### Initial velocity set to zero

```python
case1 = sym.simplify(full_sol.subs({y0:0, v0:0, a:1, b:1, c:1}))
case1
```

```python
sym.plot(case1.rhs)
sym.plot(case1.rhs,(t,-2,2))
```

#### Initial velocity set to one

```python
case2 = sym.simplify(full_sol.subs({y0:0, v0:1, a:1, b:1, c:1}))
case2
```

```python
sym.plot(case2.lhs,(t,-2,2))
```

## Calculate the phase space

As we will see in lecture, the state of our classical systems are defined as points in phase space, a hyperspace defined by ${{\bf{r}}^N},{{\bf{p}}^N}$. We will convert our sympy expression into a numerical function so that we can plot the path of $y(t)$ in phase space $y,y'$.

```python
case1
```

```python
# Import numpy library
import numpy as np

# Make numerical functions out of symbolic expressions
yfunc=sym.lambdify(t,case1.rhs,'numpy')
vfunc=sym.lambdify(t,case1.rhs.diff(t),'numpy')

# Make list of numbers
tlst=np.linspace(-2,2,100)

# Import pyplot
import matplotlib
import matplotlib.pyplot as plt
# Make plot
plt.plot(yfunc(tlst),vfunc(tlst))
plt.xlabel('$y$')
plt.ylabel("$y'$")
plt.show()
```

### Exercise 1.1

Change the initial starting conditions and see how that changes the plots. Make three different plots with different starting conditions

```python
# Your code here
case3 = sym.simplify(full_sol.subs({y0:1, v0:2, a:3, b:4, c:5}))
case3
```

```python
sym.plot(case3.rhs)
sym.plot(case3.rhs,(t,-2,2))
```

```python
case4 = sym.simplify(full_sol.subs({y0:3, v0:2, a:1, b:2, c:3}))
case4
```

```python
sym.plot(case4.rhs)
sym.plot(case4.rhs,(t,-2,2))
```

```python
case5 = sym.simplify(full_sol.subs({y0:67, v0:12, a:0, b:-12, c:72}))
case5
```

```python
sym.plot(case5.rhs)
sym.plot(case5.rhs,(t,-2,2))
```

## 2. Harmonic oscillator

Applying the harmonic oscillator force to Newton's second law leads to the following second order differential equation

$$ F = m a $$

$$ F= - \omega_0^2 m x $$

$$ a = - \omega_0^2 x $$

$$ x(t)'' = - \omega_0^2 x $$

The final expression can be rearranged into a second order homogenous differential equation, and can be solved using the methods we used above

Your goal is determine and plot the equations of motion of a 1D harmomnic oscillator

### Exercise 2.1

1. Use the methodology above to determine the equations of motion $x(t), v(t)$ for a harmonic ocillator
1. Solve for any constants by using the following initial conditions: $x(0)=x_0, v(0)=v_0$
1. Show expressions for and plot the equations of motion for the following cases:
    1. $x(0)=0, v(0)=0$
    1. $x(0)=0, v(0)>0$
    1. $x(0)>0, v(0)=0$
    1. $x(0)<0, v(0)=0$
1. Plot the phasespace diagram for the harmonic oscillator

```python
x=sym.Function("x")
x(t)
```

```python
t,w_0=sym.symbols("t, w_0")
w_0
```

```python
sym.diff(x(t),(t,1)),sym.diff(x(t),(t,2))
```

```python
dfeq2=sym.diff(x(t),(t,2))+((w_0**2)*x(t))
dfeq2
```

```python
sol2 = sym.dsolve(dfeq2)
sol2
```

```python
x0,v0=sym.symbols("x_0,v_0")
ics2=[sym.Eq(sol2.args[1].subs(t, 0), x0),
     sym.Eq(sol2.args[1].diff(t).subs(t, 0), v0)]
ics2
```

```python
solved_ics2=sym.solve(ics2)
solved_ics2
```

```python
full_sol2 = sol2.subs(solved_ics2[0])
full_sol2
```

```python
%matplotlib inline
```

```python
case1b = sym.simplify(full_sol2.subs({x0:0, v0:0, w_0:1}))
case1b
```

```python
sym.plot(case1b.rhs)
sym.plot(case1b.rhs,(t,-2,2))
```

```python
case2b = sym.simplify(full_sol2.subs({x0:0, v0:1, w_0:1}))
case2b
```

```python
sym.plot(case2b.rhs)
sym.plot(case2b.rhs,(t,-2,2))
```

```python
case3b = sym.simplify(full_sol2.subs({x0:1, v0:0, w_0:1}))
case3b
```

```python
sym.plot(case3b.rhs)
sym.plot(case3b.rhs,(t,-2,2))
```

```python
case4b = sym.simplify(full_sol2.subs({x0:-1, v0:0, w_0:1}))
case4b
```

```python
sym.plot(case4b.rhs)
sym.plot(case4b.rhs,(t,-2,2))
```

```python
# Import numpy library
import numpy as np

# Make numerical functions out of symbolic expressions
xfunc=sym.lambdify(t,case1b.rhs,'numpy')
vfunc=sym.lambdify(t,case1b.rhs.diff(t),'numpy')

# Make list of numbers
tlst=np.linspace(-2,2,100)

# Import pyplot
import matplotlib
import matplotlib.pyplot as plt
# Make plot
plt.plot(xfunc(tlst),vfunc(tlst))
plt.xlabel('$x$')
plt.ylabel("$x'$")
plt.show()
```

```python
# Import numpy library
import numpy as np

# Make numerical functions out of symbolic expressions
xfunc=sym.lambdify(t,case2b.rhs,'numpy')
vfunc=sym.lambdify(t,case2b.rhs.diff(t),'numpy')

# Make list of numbers
tlst=np.linspace(-2,2,100)

# Import pyplot
import matplotlib
import matplotlib.pyplot as plt
# Make plot
plt.plot(xfunc(tlst),vfunc(tlst))
plt.xlabel('$x$')
plt.ylabel("$x'$")
plt.show()
```

```python
# Import numpy library
import numpy as np

# Make numerical functions out of symbolic expressions
xfunc=sym.lambdify(t,case3b.rhs,'numpy')
vfunc=sym.lambdify(t,case3b.rhs.diff(t),'numpy')

# Make list of numbers
tlst=np.linspace(-2,2,100)

# Import pyplot
import matplotlib
import matplotlib.pyplot as plt
# Make plot
plt.plot(xfunc(tlst),vfunc(tlst))
plt.xlabel('$x$')
plt.ylabel("$x'$")
plt.show()
```

```python
# Import numpy library
import numpy as np

# Make numerical functions out of symbolic expressions
xfunc=sym.lambdify(t,case4b.rhs,'numpy')
vfunc=sym.lambdify(t,case4b.rhs.diff(t),'numpy')

# Make list of numbers
tlst=np.linspace(-2,2,100)

# Import pyplot
import matplotlib
import matplotlib.pyplot as plt
# Make plot
plt.plot(xfunc(tlst),vfunc(tlst))
plt.xlabel('$x$')
plt.ylabel("$x'$")
plt.show()
```

```python

```


# list_min_max_avg
Jalankan kode pada sel-sel di bawah ini sehingga identitas Anda tertampilkan dengan benar dan lakukan statistik sederhana.

```python
from IPython.core.display import HTML
HTML("")

import sys, os
sys.path.insert(1, os.path.join(sys.path[0], '../..'))

from src.student import Students as stu
nim = 10200999
print(nim, end=' ')
print(stu[nim]['name'], end=' ')
print("https://github.com/", stu[nim]['github'], sep='')
```

Bila terdapat $N$ bilangan maka rata-ratanya dapat diperoleh melalui

\begin{equation}
x_{\rm avg} = \frac1N \sum_{i = 1}^N x_i,
\end{equation}

untuk misalnya $x = \{ x_1, x_2, \dots, x_N \}$.

```python
def avg(x):
    sum = 0
    for i in x:
        sum = i
    N = len(x)
    val = sum / N
    return val

x = [1, 10, 5, 7, 9, 2, 4, 6, 8, 3]

#xmin =
#xmax =
#xavg =

print("x = ", x, sep='')
print("xmin = ", xmin, sep='')
print("xmax = ", xmax, sep='')
print("xavg = ", xavg, sep='')
```

Perbaiki kode di atas sehingga dapat diperoleh nilai $x_{\rm min}$, $x_{\rm max}$, dan $x_{\rm avg}$.


# Introduction to Natural Language Processing (NLP) in PyTorch

### Word Embeddings

Word embeddings, or word vectors, provide a way of mapping words from a vocabulary into a low-dimensional space, where words with similar meanings are close together. Let's play around with a set of pre-trained word vectors, to get used to their properties. There exist many sets of pretrained word embeddings; here, we use ConceptNet Numberbatch, which provides a relatively small download in an easy-to-work-with format (h5).

```python
# Download word vectors
from urllib.request import urlretrieve
import os
if not os.path.isfile('datasets/mini.h5'):
    print("Downloading Conceptnet Numberbatch word embeddings...")
    conceptnet_url = 'http://conceptnet.s3.amazonaws.com/precomputed-data/2016/numberbatch/17.06/mini.h5'
    urlretrieve(conceptnet_url, 'datasets/mini.h5')
```

To read an h5 file, we'll need to use the `h5py` package.
If you followed the PyTorch installation instructions in 1A, you should have it downloaded already.
Otherwise, you can install it with
```Shell
# If you environment isn't currently active, activate it:
# conda activate pytorch

pip install h5py
```
You may need to re-open this notebook for the installation to take effect.

Below, we use the package to open the `mini.h5` file we just downloaded.
We extract from the file a list of utf-8-encoded words, as well as their 300-d vectors.

```python
# Load the file and pull out words and embeddings
import h5py

with h5py.File('datasets/mini.h5', 'r') as f:
    all_words = [word.decode('utf-8') for word in f['mat']['axis1'][:]]
    all_embeddings = f['mat']['block0_values'][:]

print("all_words dimensions: {}".format(len(all_words)))
print("all_embeddings dimensions: {}".format(all_embeddings.shape))

print("Random example word: {}".format(all_words[1337]))
```

Now, `all_words` is a list of $V$ strings (what we call our *vocabulary*), and `all_embeddings` is a $V \times 300$ matrix. The strings are of the form `/c/language_code/word`—for example, `/c/en/cat` and `/c/es/gato`.

We are interested only in the English words. We use Python list comprehensions to pull out the indices of the English words, then extract just the English words (stripping the six-character `/c/en/` prefix) and their embeddings.

```python
# Restrict our vocabulary to just the English words
english_words = [word[6:] for word in all_words if word.startswith('/c/en/')]
english_word_indices = [i for i, word in enumerate(all_words) if word.startswith('/c/en/')]
english_embeddings = all_embeddings[english_word_indices]

print("Number of English words in all_words: {0}".format(len(english_words)))
print("english_embeddings dimensions: {0}".format(english_embeddings.shape))

print(english_words[1337])
```

The magnitude of a word vector is less important than its direction; the magnitude can be thought of as representing frequency of use, independent of the semantics of the word.
Here, we will be interested in semantics, so we *normalize* our vectors, dividing each by its length.
The result is that all of our word vectors are length 1, and as such, lie on a unit circle.
The dot product of two vectors is proportional to the cosine of the angle between them, and provides a measure of similarity (the bigger the cosine, the smaller the angle).

```python
import numpy as np

norms = np.linalg.norm(english_embeddings, axis=1)
normalized_embeddings = english_embeddings.astype('float32') / norms.astype('float32').reshape([-1, 1])
```

We want to look up words easily, so we create a dictionary that maps us from a word to its index in the word embeddings matrix.

```python
index = {word: i for i, word in enumerate(english_words)}
```

Now we are ready to measure the similarity between pairs of words. We use numpy to take dot products.

```python
def similarity_score(w1, w2):
    score = np.dot(normalized_embeddings[index[w1], :], normalized_embeddings[index[w2], :])
    return score

# A word is as similar with itself as possible:
print('cat\tcat\t', similarity_score('cat', 'cat'))

# Closely related words still get high scores:
print('cat\tfeline\t', similarity_score('cat', 'feline'))
print('cat\tdog\t', similarity_score('cat', 'dog'))

# Unrelated words, not so much
print('cat\tmoo\t', similarity_score('cat', 'moo'))
print('cat\tfreeze\t', similarity_score('cat', 'freeze'))

# Antonyms are still considered related, sometimes more so than synonyms
print('antonym\topposite\t', similarity_score('antonym', 'opposite'))
print('antonym\tsynonym\t', similarity_score('antonym', 'synonym'))
```

We can also find, for instance, the most similar words to a given word.

```python
def closest_to_vector(v, n):
    all_scores = np.dot(normalized_embeddings, v)
    best_words = list(map(lambda i: english_words[i], reversed(np.argsort(all_scores))))
    return best_words[:n]

def most_similar(w, n):
    return closest_to_vector(normalized_embeddings[index[w], :], n)
```

```python
print(most_similar('cat', 10))
print(most_similar('dog', 10))
print(most_similar('duke', 10))
```

We can also use `closest_to_vector` to find words "nearby" vectors that we create ourselves. This allows us to solve analogies. For example, in order to solve the analogy "man : brother :: woman : ?", we can compute a new vector `brother - man + woman`: the meaning of brother, minus the meaning of man, plus the meaning of woman. We can then ask which words are closest, in the embedding space, to that new vector.

```python
def solve_analogy(a1, b1, a2):
    b2 = normalized_embeddings[index[b1], :] - normalized_embeddings[index[a1], :] + normalized_embeddings[index[a2], :]
    return closest_to_vector(b2, 1)

print(solve_analogy("man", "brother", "woman"))
print(solve_analogy("man", "husband", "woman"))
print(solve_analogy("spain", "madrid", "france"))
```

These three results are quite good, but in general, the results of these analogies can be disappointing. Try experimenting with other analogies, and see if you can think of ways to get around the problems you notice (i.e., modifications to the `solve_analogy()` algorithm).

### Using word embeddings in deep models

Word embeddings are fun to play around with, but their primary use is that they allow us to think of words as existing in a continuous, Euclidean space; we can then use an existing arsenal of techniques for machine learning with continuous numerical data (like logistic regression or neural networks) to process text.
Let's take a look at an especially simple version of this.
We'll perform *sentiment analysis* on a set of movie reviews: in particular, we will attempt to classify a movie review as positive or negative based on its text.

We will use a [Simple Word Embedding Model](http://people.ee.duke.edu/~lcarin/acl2018_swem.pdf) (SWEM, Shen et al. 2018) to do so.
We will represent a review as the *mean* of the embeddings of the words in the review.
Then we'll train a two-layer MLP (a neural network) to classify the review as positive or negative.
As you might guess, using just the mean of the embeddings discards a lot of the information in a sentences, but for tasks like sentiment analysis, it can be surprisingly effective.

If you don't have it already, download the `movie-simple.txt` file.
Each line of that file contains

1. the numeral 0 (for negative) or the numeral 1 (for positive), followed by
2. a tab (the whitespace character), and then
3. the review itself.

Let's first read the data file, parsing each line into an input representation and its corresponding label.
Again, since we're using SWEM, we're going to take the mean of the word embeddings for all the words as our input.

```python
import string
remove_punct=str.maketrans('','',string.punctuation)

# This function converts a line of our data file into
# a tuple (x, y), where x is 300-dimensional representation
# of the words in a review, and y is its label.
def convert_line_to_example(line):
    # Pull out the first character: that's our label (0 or 1)
    y = int(line[0])

    # Split the line into words using Python's split() function
    words = line[2:].translate(remove_punct).lower().split()

    # Look up the embeddings of each word, ignoring words not
    # in our pretrained vocabulary.
    embeddings = [normalized_embeddings[index[w]] for w in words
                  if w in index]

    # Take the mean of the embeddings
    x = np.mean(np.vstack(embeddings), axis=0)
    return x, y

# Apply the function to each line in the file.
xs = []
ys = []
with open("datasets/movie-simple.txt", "r", encoding='utf-8', errors='ignore') as f:
    for l in f.readlines():
        x, y = convert_line_to_example(l)
        xs.append(x)
        ys.append(y)

# Concatenate all examples into a numpy array
xs = np.vstack(xs)
ys = np.vstack(ys)
```

```python
print("Shape of inputs: {}".format(xs.shape))
print("Shape of labels: {}".format(ys.shape))

num_examples = xs.shape[0]
```

Notice that with this set-up, our input words have been converted to vectors as part of our preprocessing.
This essentially locks our word embeddings in place throughout training, as opposed to learning the word embeddings.
Learning word embeddings, either from scratch or fine-tuned from some pre-trained initialization, is often desirable, as it specializes them for the specific task.
However, because our data set is relatively small and our computation budget for this demo, we're going to forgo learning the word embeddings for this model.
We'll revist this in a bit.

Now that we've parsed the data, let's save 20\% of the data (rounded to a whole number) for testing, using the rest for training.
The file we loaded had all the negative reviews first, followed by all the positive reviews, so we need to shuffle it before we split it into the train and test splits.
We'll then convert the data into PyTorch Tensors so we can feed them into our model.

```python
print("First 20 labels before shuffling: {0}".format(ys[:20, 0]))

shuffle_idx = np.random.permutation(num_examples)
xs = xs[shuffle_idx, :]
ys = ys[shuffle_idx, :]

print("First 20 labels after shuffling: {0}".format(ys[:20, 0]))
```

```python
import torch

num_train = 4*num_examples // 5

x_train = torch.tensor(xs[:num_train])
y_train = torch.tensor(ys[:num_train], dtype=torch.float32)

x_test = torch.tensor(xs[num_train:])
y_test = torch.tensor(ys[num_train:], dtype=torch.float32)
```

We could format each batch individually as we feed it into the model, but to make it easier on ourselves, let's create a TensorDataset and DataLoader as we've used in the past for MNIST.

```python
reviews_train = torch.utils.data.TensorDataset(x_train, y_train)
reviews_test = torch.utils.data.TensorDataset(x_test, y_test)

train_loader = torch.utils.data.DataLoader(reviews_train, batch_size=100, shuffle=True)
test_loader = torch.utils.data.DataLoader(reviews_test, batch_size=100, shuffle=False)
```

Time to build our model in PyTorch.

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

First we build the model, organized as a `nn.Module`.
We could make the number of outputs for our MLP the number of classes for this dataset (i.e. 2).
However, since we only have two output classes here ("positive" vs "negative"), we can instead produce a single output value, calling everything greater than $0$ "postive" and everything less than $0$ "negative".
If we pass this output through a sigmoid operation, then values are mapped to $[0,1]$, with $0.5$ being the classification threshold.

```python
class SWEM(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(300, 64)
        self.fc2 = nn.Linear(64, 1)

    def forward(self, x):
        x = self.fc1(x)
        x = F.relu(x)
        x = self.fc2(x)
        return x
```

To train the model, we instantiate the model.
Notice that since we are only doing binary classification, we use the binary cross-entropy (BCE) loss instead of the cross-entropy loss we've seen before.
We use the "with logits" version for numerical stability.

```python
## Training
# Instantiate model
model = SWEM()

# Binary cross-entropy (BCE) Loss and Adam Optimizer
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# Iterate through train set minibatchs
for epoch in range(250):
    correct = 0
    num_examples = 0
    for inputs, labels in train_loader:
        # Zero out the gradients
        optimizer.zero_grad()

        # Forward pass
        y = model(inputs)
        loss = criterion(y, labels)

        # Backward pass
        loss.backward()
        optimizer.step()

        predictions = torch.round(torch.sigmoid(y))
        correct += torch.sum((predictions == labels).float())
        num_examples += len(inputs)

    # Print training progress
    if epoch % 25 == 0:
        acc = correct/num_examples
        print("Epoch: {0} \t Train Loss: {1} \t Train Acc: {2}".format(epoch, loss, acc))

## Testing
correct = 0
num_test = 0

with torch.no_grad():
    # Iterate through test set minibatchs
    for inputs, labels in test_loader:
        # Forward pass
        y = model(inputs)

        predictions = torch.round(torch.sigmoid(y))
        correct += torch.sum((predictions == labels).float())
        num_test += len(inputs)

print('Test accuracy: {}'.format(correct/num_test))
```

We can now examine what our model has learned, seeing how it responds to word vectors for different words:

```python
# Check some words
words_to_test = ["exciting", "hated", "boring", "loved"]

for word in words_to_test:
    x = torch.tensor(normalized_embeddings[index[word]].reshape(1, 300))
    print("Sentiment of the word '{0}': {1}".format(word, torch.sigmoid(model(x))))
```

Try some words of your own!
You can also try changing up the model and re-training it to see how the results change.
Can you modify the architecture to get better performance?
Alternatively, can you simplify the model without sacrificing too much accuracy?
What if you try to classify the mean embeddings directly?

### Learning Word Embeddings

In the previous example, we used pre-trained word embeddings, but didn't learn them.
The word embeddings were part of preprocessing and remained unchanged throughout training.
If we have enough data though, we might prefer to learn the word embeddings along with our model.
Pre-trained word embeddings are typically trained on large corpora with unsupervised objectives, and are often non-specific.
If we have enough data, we may prefer to learn the word embeddings, either from scratch or with fine-tuning, as making them specific to the task may improve performance.

How do we learn word embeddings?
To do so, we need to make them a part of our model, rather than as part of loading the data.
In PyTorch, the preferred way to do so is with the `nn.Embedding`.
Like the other `nn` layers we've seen (e.g. `nn.Linear`), `nn.Embedding` must be instantiated first.
There are two required arguments for instantiation are the number of embeddings (i.e. the vocabulary size $V$) and the dimension of word embeddings (300, in our previous example).

```python
VOCAB_SIZE = 5000
EMBED_DIM = 300

embedding = nn.Embedding(VOCAB_SIZE, EMBED_DIM)
```

Under the hood, this creates a word embedding matrix that is $5000 \times 300$.

```python
embedding.weight.size()
```

Notice that this matrix is basically a 300 dimensional word embedding for each of the 5000 words, stacked on top of each other.
Looking up a word embedding in this embedding matrix is simply selecting a specific row of this matrix, corresponding to the word.

When word embeddings are learned, `nn.Embedding` look-up is often one of the first operations in a model module.
For example, if we were to learn the word embeddings for our previous SWEM model, the model might instead look like this:

```python
class SWEMWithEmbeddings(nn.Module):
    def __init__(self, vocab_size, embedding_size, hidden_dim, num_outputs):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_size)
        self.fc1 = nn.Linear(embedding_size, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, num_outputs)

    def forward(self, x):
        x = self.embedding(x)
        x = torch.mean(x, dim=0)
        x = self.fc1(x)
        x = F.relu(x)
        x = self.fc2(x)
        return x
```

Here we've abstracted the size of the various layers of the model as constructor arguments, so we need to specify those hyperparameters at initialization.

```python
model = SWEMWithEmbeddings(
    vocab_size = 5000,
    embedding_size = 300,
    hidden_dim = 64,
    num_outputs = 1,
)
print(model)
```

Note that by making embedding part of our model, the expected input to the `forward()` function is now the word tokens for the input sentence, so we would have to modify our data input pipeline as well.
We'll see how this might be done in the next notebook (4B).

### Recurrent Neural Networks (RNNs)

In the context of deep learning, sequential data is commonly modeled with Recurrent Neural Networks (RNNs).
As natural language can be viewed as a sequence of words, RNNs are commonly used for NLP.
As with the fully connected and convolutional networks we've seen before, RNNs use combinations of linear and nonlinear transformations to project the input into higher level representations, and these representations can be stacked with additional layers.

#### Sentences as sequences
The key difference between sequential models and the previous models we've seen is the presence of a "time" dimension: words in a sentence (or paragraph, document) have an ordering to them that convey meaning:

In the example sequence above, the word "Recurrent" is the $t=1$ word, which we denote $w_1$; similarly, "neural" is $w_2$, and so on.
As the preceding sections have hopefully impressed upon you, it is often more advantageous to model words as embedding vectors $x_1, ..., x_T$, rather than one-hot vectors (which tokens $w_1,...w_T$ correspond to), so our first step is often to do an embedding table look-up for each input word.
Let's assume 300-dimensional word embeddings and, for simplicity, a minibatch of size 1.

```python
mb = 1
x_dim = 300
sentence = ["recurrent", "neural", "networks", "are", "great"]

xs = []
for word in sentence:
    xs.append(torch.tensor(normalized_embeddings[index[word]]).view(1, x_dim))

xs = torch.stack(xs, dim=0)
print("xs shape: {}".format(xs.shape))
```

Notice that we have formatted our inputs as ($\text{words} \times \text{minibatch} \times \text{embedding dimension}$).
This is the preferred input ordering for PyTorch RNNs.

Let's say we want to process this example.
In our previous sentiment analysis example, we just took the average embedding across time, treating the input as a "bag-of-words."
For simple problems, this can work surprisingly well, but as you might imagine, the ordering of words in a sentence is often important, and sometimes, we'd like to be able to model this temporal meaning as well.
Enter RNNs.

#### Review: Fully connected layer

Before we introduce the RNN, let's first again revist the fully connected layer that we used in our logistic regression and multilayer perceptron examples, with a few changes in notation:

\begin{align*}
h = f(x W + b)
\end{align*}

Instead of calling the result of the fully connected layer $y$, we're going to call it $h$, for hidden state.
The variable $y$ is usually reserved for the final layer of the neural network; since logistic regression was a single layer, using $y$ was fine.
However, if we assume there is more than one layer, it is more common to refer to the intermediate representation as $h$.
Note that we also use $f()$ to denote a nonlinear activation function.
In the past, we've seen $f()$ as a $\text{ReLU}$, but this could also be a $\sigma()$ or $\tanh()$ nonlinearity.
Visualized:

The key thing to notice here is that we project the input $x$ with a linear transformation (with $W$ and $b$), and then apply a nonlinearity to the output, giving us $h$.
During training, our goal is to learn $W$ and $b$.

#### A basic RNN

Unlike the previous examples we've seen using fully connected layers, sequential data have multiple inputs $x_1, ..., x_T$, instead of a single $x$.
We need to adapt our models accordingly for an RNN.
While there are several variations, a common basic formulation for an RNN is the Elman RNN, which is as follows*:

\begin{align}
h_t = \tanh((x_t W_x + b_x) + (h_{t-1} W_h + b_h))
\end{align}

where $\tanh()$ is the hyperbolic tangent, a nonlinear activation function.
RNNs process words one at a time in sequence ($x_t$), producing a hidden state $h_t$ at every time step.
The first half of the above equation should look familiar; as with the fully connected layer, we are linearly transforming each input $x_t$, and then applying a nonlinearity.
Notice that we apply the same linear transformation ($W_x$, $b_x$) at every time step.
The difference is that we also apply a separate linear transform ($W_h$, $b_h$) to the previous hidden state $h_{t-1}$ and add it to our projected input.
This feedback is called a *recurrent* connection.

These directed cycles in the RNN architecture gives them the ability to model temporal dynamics, making them particularly suited for modeling sequences (e.g. text).
We can visualize an RNN layer as follows:

We can unroll an RNN through time, making the sequential nature of them more obvious:

You can think of these recurrent connections as allowing the model to consider previous hidden states of a sequence when calculating the hidden state for the current input.

<font size="1">*Note: We don't actually need two separate biases $b_x$ and $b_h$, as you can combine both biases into a single learnable parameter $b$.
However, writing it separately helps make it clear that we're performing a linear transformation on both $x_t$ and $h_{t-1}$.
Speaking of combining variables, we can also express the above operation by concatenating $x_t$ and $h_{t-1}$ into a single vector $z_t$, and then performing a single matrix multiply $z_t W_z + b$, where $W_z$ is essentially $W_x$ and $W_h$ concatenated.
Indeed this is how many "official" RNNs modules are implemented, as the reduction in the number of separate matrix multiply operations makes it computationally more effecient.
These are implementation details though.</font>

#### RNNs in PyTorch
How would we implement an RNN in PyTorch?
There are quite a few ways, but let's build the Elman RNN from scratch first, using the input sequence "recurrent neural networks are great".

```python
# As always, import PyTorch first
import numpy as np
import torch
```

In an RNN, we project both the input $x_t$ and the previous hidden state $h_{t-1}$ to some hidden dimension, which we're going to choose to be 128.
To perform these operations, we're going to define some variables we're going to learn.

```python
h_dim = 128

# For projecting the input
Wx = torch.randn(x_dim, h_dim)/np.sqrt(x_dim)
Wx.requires_grad_()
bx = torch.zeros(h_dim, requires_grad=True)

# For projecting the previous state
Wh = torch.randn(h_dim, h_dim)/np.sqrt(h_dim)
Wh.requires_grad_()
bh = torch.zeros(h_dim, requires_grad=True)

print(Wx.shape, bx.shape, Wh.shape, bh.shape)
```

For convenience, we define a function for one time step of the RNN.
This function take the current input $x_t$ and previous hidden state $h_{t-1}$, performs the linear transformations $x W_x + b_x$ and $h W_h + b_h$, and then a hyperbolic tangent nonlinearity.

```python
def RNN_step(x, h):
    h_next = torch.tanh((torch.matmul(x, Wx) + bx) + (torch.matmul(h, Wh) + bh))

    return h_next
```

Each step of our RNN is going to require feeding in an input (i.e. the word representation) and the previous hidden state (the summary of preceding sequence).
Note that at the beginning of a sentence, we don't have a previous hidden state, so we initialize it to some value, for example all zeros:

```python
# Word embedding for first word
x1 = xs[0, :, :]

# Initialize hidden state to 0
h0 = torch.zeros([mb, h_dim])
```

To take one time step of the RNN, we call the function we wrote, passing in $x_1$ and $h_0$.
In this case,

```python
# Forward pass of one RNN step for time step t=1
h1 = RNN_step(x1, h0)

print("Hidden state h1 dimensions: {0}".format(h1.shape))
```

We can call the `RNN_step` function again to get the next time step output from our RNN.

```python
# Word embedding for second word
x2 = xs[1, :, :]

# Forward pass of one RNN step for time step t=2
h2 = RNN_step(x2, h1)

print("Hidden state h2 dimensions: {0}".format(h2.shape))
```

We can continue unrolling the RNN as far as we need to.
For each step, we feed in the current input ($x_t$) and previous hidden state ($h_{t-1}$) to get a new output.

#### Using `torch.nn`

In practice, much like fully connected and convolutional layers, we typically don't implement RNNs from scratch as above, instead relying on higher level APIs.
PyTorch has RNNs implemented in the `torch.nn` library.

```python
import torch.nn

rnn = nn.RNN(x_dim, h_dim)
print("RNN parameter shapes: {}".format([p.shape for p in rnn.parameters()]))
```

Note that the RNN created by `torch.nn` produces parameters of the same dimensions as our from scratch example above.

To perform a forward pass with an RNN, we pass the entire input sequence to the `forward()` function, which returns the hidden states at every time step (`hs`) and the final hidden state (`h_T`).

```python
hs, h_T = rnn(xs)

print("Hidden states shape: {}".format(hs.shape))
print("Final hidden state shape: {}".format(h_T.shape))
```

What do we do with these hidden states?
It depends on the model and task.
Just like multilayer perceptrons and convolutional neural networks, RNNs can be stacked in multiple layers as well.
In this case, the outputs $h_1, ..., h_T$ are the sequential inputs to the next layer.
If the RNN layer is the final layer, $h_T$ or the mean/max of $h_1, ..., h_T$ can be used as a summary encoding of the data sequence.
What is being predicted can also have an impact on what the RNN outputs are ultimately used for.

#### Gated RNNs

While the RNNs we've just explored can successfully model simple sequential data, they tend to struggle with longer sequences, with [vanishing gradients](https://en.wikipedia.org/wiki/Vanishing_gradient_problem) an especially big problem.
A number of RNN variants have been proposed over the years to mitigate this issue and have been shown empirically to be more effective.
In particular, Long Short-Term Memory (LSTM) and the Gated Recurrent Unit (GRU) have seen wide use recently in deep learning.
We're not going to go into detail here about what structural differences they have from vanilla RNNs; a fantastic summary can be found [here](https://colah.github.io/posts/2015-08-Understanding-LSTMs/).
Note that "RNN" as a name is somewhat overloaded: it can refer to both the basic recurrent model we went over previously, or recurrent models in general (including LSTMs and GRUs).

LSTMs and GRUs layers can be created in much the same way as basic RNN layers.
Again, rather than implementing it yourself, it's recommended to use the `torch.nn` implementations, although we highly encourage that you peek at the source code so you understand what's going on under the hood.

```python
lstm = nn.LSTM(x_dim, h_dim)
print("LSTM parameters: {}".format([p.shape for p in lstm.parameters()]))

gru = nn.GRU(x_dim, h_dim)
print("GRU parameters: {}".format([p.shape for p in gru.parameters()]))
```

### Torchtext

Much like PyTorch has [Torchvision](https://pytorch.org/docs/stable/torchvision/index.html) for computer vision, PyTorch also has [Torchtext](https://torchtext.readthedocs.io/en/latest/) for natural language processing.
As with Torchvision, Torchtext has a number of popular NLP benchmark datasets, across a wide range of tasks (e.g. sentiment analysis, language modeling, machine translation).
It also has a few pre-trained word embeddings available as well, including the popular Global Vectors for Word Representation (GloVe).
If you need to load your own dataset, Torchtext has a number of useful containers that can make the data pipeline easier.

You'll need to install TorchText to use it:

```Shell
# If you environment isn't currently active, activate it:
# conda activate pytorch

pip install torchtext
```

### Other materials:
Natural Language Processing can be several full courses on its own at most universities, both with or without neural networks.
Here are some additional reads:

- [Fantastic introduction to LSTMs and GRUs](https://colah.github.io/posts/2015-08-Understanding-LSTMs/)
- [Popular blog post on the effectiveness of RNNs](http://karpathy.github.io/2015/05/21/rnn-effectiveness/)


# Komplexe Zahlen für Wechselstrom

Um die Knotenregel und die Maschenregel für Wechselströme anwenden zu können, kann man entweder einfach alles im Zeitbereich lösen. Das ist in einem Jupyter Notebook auch ganz einfach und elegant möglich, da der Computer einem die mühsame Erstellung einer Wertetabelle und die Berechnung
von Summen und so weiter automatisiert.

Allerdings ist es trotzdem noch recht umständlich, wenn man so etwas nur mit dem Rechner berechnen kann. Außerdem braucht man bei Zeitfunktionen länger, um diese zu erfassen: Amplitude und Phase sind nicht auf einen Blick ersichtlich. Übersichtlicher ist die Zeigerdarstellung für komplexe Zahlen.

```python
# Bibliotheken laden
import numpy as np
import matplotlib.pyplot as plt
# Graphikparameter
plt.style.use('classic')
plt.rcParams.update({'font.size':24})
plt.rcParams.update({'figure.autolayout': True})
```

## Zeitfunktionen für Wechselspannungen

### Spannung $u_1(t)$ und Berechnung des Effektivwertes $U_1$

```python
# Zeitvektor
t = np.linspace(-10e-3,50e-3,1000)
# Amplitude der Spannung
udach1 = 315 # in Volt
# Frequenz
f = 50 # in Hertz
# Kreisfrequenz
omega = 2*np.pi*f # in Hertz
# Nullphasenwinkel der Spannung
phiu1 = np.pi/3 # in rad
phiu1g = 180*phiu1/np.pi # in Grad
# Spannung
u1 = udach1*np.cos(omega*t+phiu1)
# Effektivwert
U1 = udach1/np.sqrt(2) # in Volt

# Diagramm erstellen
plt.plot(1000*t,u1,label='$u_1(t)$') # u1
plt.plot(1000*t,U1*np.ones(len(t)),'k--',label='$U_1$') # U1
plt.plot(np.zeros(2),[-1.1*udach1,1.1*udach1],'k-') # Hilfslinie
plt.plot([1000*t[0],1000*t[-1]],np.zeros(2),'k-') # Hilfslinie
plt.xlabel('Zeit $t$ [ms]') # x-Achsenbeschriftung
plt.grid() # Gitternetz
plt.legend(loc='best') # Legende
```

**Aufgabe: Verändern Sie den Wert der Phase `phiu1` und beobachten Sie, wie sich das Signal nach rechts oder nach links verschiebt. Was passiert, wenn `phiu1` negativ ist?**

*Antwort:*

### Spannung $u_2(t)$ und Berechnung des Effektivwertes $U_2$

```python
# Amplitude der zweiten Spannung
udach2 = np.sqrt(2)*150
# Nullphasenwinkel der zweiten Spannung
phiu2 = -np.pi/2 # in rad
phiu2g = 180*phiu2/np.pi # in Grad
# zweite Spannung
u2 = udach2*np.cos(omega*t+phiu2)
# Effektivwert
U2 = udach2/np.sqrt(2) # in Volt

# Diagramm erstellen
plt.plot(1000*t,u2,'g-',label='$u_2(t)$') # u2
plt.plot(1000*t,U2*np.ones(len(t)),'k--',label='$U_2$') # U2
plt.plot(np.zeros(2),[-1.1*udach2,1.1*udach2],'k-') # Hilfslinie
plt.plot([1000*t[0],1000*t[-1]],np.zeros(2),'k-') # Hilfslinie
plt.xlabel('Zeit $t$ [ms]') # x-Achsenbeschriftung
plt.grid()
plt.legend(loc='best') # Legende
```

**Aufgabe: Verändern Sie den Wert der Amplitude `udach2` des Signals und beobachten Sie den Effekt. Beachten Sie dabei, dass sich die y-Achsen-Skala an die benötigten Werte anpasst.**

*Antwort:*

### Summe $u_S(t)=u_1(t)+u_2(t)$ mit Effektivwert $U_S$

```python
# Bestimmung des Signals us
us = u1 + u2
# Periode
T = 1/f
# Index Tidx des Zeitvektors t nach Verstreichen der Periode T
Tidx = (np.abs(t - T)).argmin()
# Effektivwert
US = np.sqrt(np.mean((us[0:Tidx])**2))
print(US)
# Amplitude
usdach = US*np.sqrt(2)

# Diagramm der Summe der beiden Spannungen:
# - wir machen 3 Diagramme untereinander
# - so kann man nachprüfen, ob die Summe stimmt
fig, ax = plt.subplots(3, 1, sharex=True, sharey=True, figsize=(8, 7))
# oberes Diagramm: u1
ax[0].plot(1000*t,u1,'b-') # u1, python fängt immer bei 0 zu zählen an
ax[0].set_ylabel('$u_1(t)$') # y-Achsentitel, set_ylabel() statt ylabel()
ax[0].grid() # Gitternetzlinien
# mittleres Diagramm: u2
ax[1].plot(1000*t,u2,'g-') # u2
ax[1].set_ylabel('$u_2(t)$') # y-Achsentitel
ax[1].grid() # Gitternetzlinien
# unteres Diagramm: us = u1+u2
ax[2].plot(1000*t,us,'r-') # Summe
ax[2].set_ylabel(r'$u_S=u_1(t)+u_2(t)$') # y-Achsentitel
ax[2].grid() # Gitternetzlinien
ax[2].set_xlabel(r'Zeit $t$ [ms]') # x-Achsentitel nur unten
```

Es ergibt sich also eine neue Schwingung aus der Summe, deren Amplitude $\hat{u_S}$ python auch ausgeben kann und zwar mit der Formel, die im Skript beschrieben ist:

\begin{equation}
\hat{u_S}=\sqrt{\hat{u_1}^2+\hat{u_2}^2+2\hat{u_1}\hat{u_2}\cos(\varphi_1-\varphi_2)}
\end{equation}

```python
us = np.sqrt(udach1**2 + udach2**2 + 2*udach1*udach2*np.cos(phiu1-phiu2))
print('us = '+str(np.round(us,1))+' V')
```

    us = 168.8 V

*Das ist die Amplitude, d.h. das müsste $\sqrt{2}\cdot U_S$ sein.*

**Aufgabe: Wir können testen, ob dieser Wert der gleiche ist wie das Maximum von $u_S(t)$. Verwenden Sie dafür in einer Code-Zelle den Befehl `print(np.max(us))` und kommentieren Sie in der Text-Zelle danach.**

```python
# hier Ihren Code einfügen
```

*Kommentar*

**Aufgabe: Ändern Sie Amplitude und Phasenwinkel von $u_1(t)$ so ab, dass als Summe der beiden Signale $u_S(t)$ a) 0 bzw. b) das Doppelte von $u_2(t)$ herauskommt.**

*a) udach1 muss ... und phiu1 muss ... sein, damit $u_S(t)=0$*

*b) udach1 muss ... und phiu1 muss ... sein, damit $u_S(t)=2\cdot u_2(t)$*

## Zeigerdarstellung von Schwingungen

Wir hatten ja im Skript gesehen, dass eine Zeigerdarstellung sich in eine Cosinusdarstellung im Zeitbereich überführen lässt und umgekehrt.

Eine ziemlich gute Animation davon, findet sich in geogebra: https://www.geogebra.org/m/m7RHfryq#material/FJtrEDAr

Sie können dort beobachten, wie sich jeweils die x-Auslenkung ($\cos$) und die y-Auslenkung ($\sin$) des Zeigers über der Zeit verhalten. Dabei ist in der Animation nur der Nullphasenwinkel $\varphi=0^\circ$ vorgesehen.

Was man allerdings beim längeren Beobachten der Animation feststellt, ist, dass das Zeiger sich nach jeweils einer Umdrehung wieder am gleichen Ort wie vorher befindet und dass er immer gleich schnell rotiert, weil ja die Frequenz konstant bleibt.

Man könnte also einfach eine **Momentaufnahme** der Zeigerbewegung zu einem definierten Zeitpunkt machen und daraus entnehmen, welche Funktion zu sehen ist. Wir versuchen das nun für die oben definierten Spannungsfunktionen und stellen den Zeiger zum Zeitpunkt $t=0$ mit dem entsprechenden Nullphasenwinkel dar. Entsprechend der elektrotechnischen Konventionen hat er immer die **Länge des Effektivwertes**.

Die x-Auslenkung des Zeigers nennt man **Realteil** oder in der Elektrotechnik auch **Wirkanteil**. Die y-Ausrichtung des Zeigers heißt **Imaginärteil** oder in der Elektrotechnik auch **Blindanteil**. Genauso wie in der oben verlinkten geogebra-App gezeigt, können wir diese Anteile ausrechnen:

```python
# Wirkanteile der Spannungen u1 und u2
UW1 = U1*np.cos(phiu1)
UW2 = U2*np.cos(phiu2)
# Blindanteile der Spannungen u1 und u2
UB1 = U1*np.sin(phiu1)
UB2 = U2*np.sin(phiu2)
```

Nun stellen wir die Spannungen $\underline{U_1}=U_{1,W}+jU_{1,B}$ und $\underline{U_2}=U_{2,W}+jU{1,B}$ als Zeiger dar und berechnen daraus die Zeigerdarstellung für die Summe der beiden Spannung, indem wir die beiden Zeiger addieren.

```python
# Summenzeiger US
UWS = UW1 + UW2
UBS = UB1 + UB2
```

```python
# für den Hilfskreis benötigte Variablen, jeweils für jede Spannung
theta = np.linspace(0, 2*np.pi, 1000) # einmal alle Winkel im Kreis
# für U1
uh1x = U1*np.cos(theta) # x-Komponenten aller Punkte im Kreis
uh1y = U1*np.sin(theta) # y-Komponenten aller Punkte im Kreis
# für U2
uh2x = U2*np.cos(theta) # x-Komponenten aller Punkte im Kreis
uh2y = U2*np.sin(theta) # y-Komponenten aller Punkte im Kreis
# für US
uhsx = US*np.cos(theta) # x-Komponenten aller Punkte im Kreis
uhsy = US*np.sin(theta) # y-Komponenten aller Punkte im Kreis

# Zeigerdiagramme der drei Spannungen
# - wir machen 3 Diagramme nebeneinander
fig, ax = plt.subplots(1, 3, sharex=True, sharey=True, figsize=(8, 4))
# linkes Diagramm U1W + jU1B
ax[0].set_aspect('equal', adjustable='box') # Skalierung x wie y
ax[0].plot(uh1x,uh1y,'k:') # gepunkteter Hilfskreis mit Radius U1
ax[0].set_xlabel(r'$U_{1,W}$ [V]',color='blue') # x-Achsentitel
ax[0].set_ylabel(r'$U_{B}$ [V]') # y-Achsentitel
ax[0].annotate('', xytext = (0, 0),xy = (UW1, UB1),
             arrowprops = {'facecolor' : 'blue',
                           'edgecolor' : 'blue',
                           'linewidth' : .001}) # Zeiger U1
ax[0].grid() # Gitternetzlinien
# mittleres Diagramm: U2W + jU2B
ax[1].set_aspect('equal', adjustable='box') # Skalierung x wie y
ax[1].plot(uh2x,uh2y,'k:') # gepunkteter Hilfskreis mit Radius U1
ax[1].set_xlabel(r'$U_{2,W}$ [V]',color='green') # x-Achsentitel
ax[1].annotate('', xytext = (0, 0),xy = (UW2, UB2),
             arrowprops = {'facecolor' : 'green',
                           'edgecolor' : 'green',
                           'linewidth' : .001}) # Zeiger U2
ax[1].grid() # Gitternetzlinien
# rechtes Diagramm: USW + jUSB
ax[2].set_aspect('equal', adjustable='box') # Skalierung x wie y
ax[2].plot(uhsx,uhsy,'k:') # gepunkteter Hilfskreis mit Radius U1
ax[2].set_xlabel(r'$U_{S,W}$ [V]',color='red') # x-Achsentitel
ax[2].annotate('', xytext = (0, 0),xy = (UW1, UB1),
             arrowprops = {'facecolor' : 'blue',
                           'edgecolor' : 'blue',
                           'linewidth' : .001}) # Zeiger U1
ax[2].annotate('', xytext = (UW1, UB1),xy = (UW1+UW2, UB1+UB2),
             arrowprops = {'facecolor' : 'green',
                           'edgecolor' : 'green',
                           'linewidth' : .001}) # Zeiger U2
ax[2].annotate('', xytext = (0, 0),xy = (UWS, UBS),
             arrowprops = {'facecolor' : 'red',
                           'edgecolor' : 'red',
                           'linewidth' : .001}) # Zeiger US
ax[2].grid() # Gitternetzlinien
```

Wenn man genau hinsieht, dann erkennt man, dass der rote Zeiger $\underline{U_S}=\underline{U_1}+\underline{U_2}$ aus der Vektoraddition der anderen beiden Zeiger entsteht.

Auch das können python und Taschenrechner, wenn man die Zahlen von vorne herein komplex anlegt:

```python
Utest1 = complex(UW1,UB1)
print('U1 komplex: ',Utest1)
Utest2 = complex(UW2, UB2)
print('U2 komplex: ', Utest2)
```

    U1 komplex:  (111.36931803688125+192.89731724417524j)
    U2 komplex:  (9.184850993605149e-15-150j)

Und dann können python und der komplexe-Zahlen-fähige Taschenrechner auch damit rechnen:

**Aufgabe: Rechnen Sie nach, wie groß die Summe von `Utest1` und `Utest2` ist und zwar mit python und mit Ihrem Taschenrechner (suchen Sie sich ein Tutorial, indem Sie nach Ihrem Taschenrechner-Modell und komplexe Zahlen im Internet suchen).**

*Ergebnis mit python:*

*Ergebnis mit dem Taschenrechner*:

*Kommentar*

**Aufgabe: Führen Sie nun folgende weitere Rechenoperationen mit der komplexen Zahl $\underline{x_1} = 3 + 4j$ und der komplexen Zahl $\underline{x_2} = 4 + 5j$ durch:**

* Addition $\underline{x_1} + \underline{x_2}$

* Subtraktion $\underline{x_1} - \underline{x_2}$

* Betrag $|\underline{x_1}|$ (in python `np.abs(x1)`)

* Phase $\varphi_1=\arctan(\frac{\Im(\underline{x_1})}{\Re(\underline{x_1})})$ (in python `np.arctan(Imaginärteil/Realteil)`)

* rechnen Sie die Phase von rad in Grad um

* Inversion $\underline{x_2}^{-1}=\frac{1}{\underline{x_2}}$

* Division $\frac{\underline{x_1}}{\underline{x_2}}$

* Multiplikation $\underline{x_1}\cdot \underline{x_2}$

*Beschreiben Sie hier die Ergebnisse.*