# [NTDS'18] milestone 4: graph signal processing
[ntds'18]: https://github.com/mdeff/ntds_2018

[Rodrigo Pena](https://people.epfl.ch/254838), [EPFL LTS2](http://lts2.epfl.ch)

## Students

* Team: 50
* Students: Görkem Çamli, Raphael Laporte, Ilija Gjorgjiev, Murat Genc
* Dataset: Spammers on Social Network

## Rules

* Milestones have to be completed by teams. No collaboration between teams is allowed.
* Textual answers shall be short. Typically one to two sentences.
* Code has to be clean.
* You cannot import any other library than we imported.
* When submitting, the notebook is executed and the results are stored. I.e., if you open the notebook again it should show numerical results and plots. We won't be able to execute your notebooks.
* The notebook is re-executed from a blank state before submission. That is to be sure it is reproducible. You can click "Kernel" then "Restart & Run All" in Jupyter.

## Objective

The goal of this milestone is to do some Graph Signal Processing (GSP) on the data of your project.

### A note about plotting

There are several questions in this milestone that ask you to plot a signal on your network.
There are several ways from which you could approach it.
In all cases, compute the position of the nodes a single time at the beginning, as this is likely to be a costly operation.
Using a single layout for all the graph plots will also make it easier to compare the plots.
Indeed, the only thing changing between plots is the signal displayed.
You can represent the features/labels lying on the graph via node **colors**.
To do so, make sure to have a consistent color map throughout and remember to display a colorbar and scale in all plots, so that we can tell what numbers the colors represent.

* An option is to use the **Laplacian eigenmaps** that you have seen in the previous milestone to embed your graph on the plane. For example:
  ```
  from matplotlib import pyplot as plt
  plt.scatter(eigenvectors[:, 1], eigenvectors[:, 2], c=signal, alpha=0.5)
  plt.colorbar()
  ```
* Another option is to use the plotting capabilities of **[NetworkX](https://networkx.github.io)**.
  See the documentation of its [drawing methods](https://networkx.github.io/documentation/stable/reference/drawing.html).
  For example:
  ```
  import networkx as nx
  graph = nx.from_scipy_sparse_matrix(adjacency)
  coords = nx.spring_layout(graph)  # Force-directed layout.
  coords = eigenvectors[:, 1:3]  # Laplacian eigenmaps.
  nx.draw_networkx_nodes(graph, coords, node_size=60, node_color=signal)
  nx.draw_networkx_edges(graph, coords, alpha=0.3)
  ```
* Another option is to use the plotting capabilities of the **[PyGSP](https://github.com/epfl-lts2/pygsp)**, a Python package for Graph Signal Processing.
  **Note that your are forbidden to use the PyGSP for anything else than plotting.**
  See the documentation of its [plotting utilities](https://pygsp.readthedocs.io/en/stable/reference/plotting.html).
  For example:
  ```
  import pygsp as pg
  graph = pg.graphs.Graph(adjacency)
  graph.set_coordinates('spring')  # Force-directed layout.
  graph.set_coordinates(eigenvectors[:, 1:3])  # Laplacian eigenmaps.
  graph.plot_signal(signal)
  ```
* Yet another option is to save your graph on disk, use **[Gephi](https://gephi.org)** externally, to visualize the graph, save the graph with the Gephi coordinates and finally load the nodes coordinates back into the notebook.

We encourage you to try all the above methods before making your choice. Then be consistent and use only one throughout the milestone.
NetworkX and PyGSP should already be installed in your environement. If that's not the case, install with `conda install networkx pygsp` (after activating the `ntds_2018` environment).

## 0 - Load your network

```python
%matplotlib inline
```

If you get a `No module named 'pyunlocbox'` error when running the below cell, install the [pyunlocbox](https://github.com/epfl-lts2/pyunlocbox) with `conda install pyunlocbox` (after activating the `ntds_2018` environment).

```python
import numpy as np
from scipy import sparse
import scipy.sparse.linalg
from matplotlib import pyplot as plt
from pyunlocbox import functions, solvers
```

For this milestone, all we will need is a set of features/labels for each of the nodes on the network, as well as the Laplacian, $L,$ and Gradient, $\nabla_G,$ matrices that you have computed for your network while working on milestone 3.

Import those objects in the cell below (or recompute the Laplacian and Gradient from your stored adjacency matrix, if you wish).

_Note_: If your features/labels are not floating-point numbers, please convert them. For example, if your data has labels "cat" and "dog" for nodes that represent cats or dogs, respectively, you may assign the number `1.0` for the label "cat" and the number `-1.0` for the label "dog".

```python
import pandas as pd
features = pd.read_csv('features.csv')
edges = pd.read_csv("edges.csv")
num_edges = len(edges)
edges.drop(columns=["Unnamed: 0"], inplace=True)
```

```python
adjacency = scipy.sparse.load_npz("adjacency_undirected_sparse_csr.npz")
adjacency_org = scipy.load("undirected_adjacency.npy")
n_nodes =  adjacency.shape[0]
```

```python
# combinatorial Laplacian, L = D-A
laplacian = scipy.sparse.load_npz("laplacian_combinatorial.npz")   # or  scipy.sparse.load_npz("laplacian_normalized.npz")

# gradient equals to S^t ( S is the incidence matrix (column being edges, and rows being nodes)
#gradient = # Your code here.

# read from features.csv spammers label
labels = np.array(features[["Spammer Label"]])

n_nodes = adjacency.shape[0]
```

```python
laplacian
```

    <62173x62173 sparse matrix of type '<class 'numpy.float32'>'
    	with 196011 stored elements in Compressed Sparse Row format>

## 1 - Graph Fourier Transform

In this section we will observe how your feature/label vector looks like in the "Graph Fourier" domain.

### Question 1

Compute the Fourier basis vectors and the Laplacian eigenvalues. Make sure to order those from smaller to larger, $\lambda_0 \leq \lambda_1 \leq \dots \leq \lambda_{N-1},$ and use the same ordering for the Fourier basis vectors.

```python
eigenvalues, eigenvectors = sparse.linalg.eigsh(laplacian, k=1000, which='SM',tol=0.001)
```

```python
np.save("eigenvalues" ,eigenvalues)
np.save("eigenvectors" ,eigenvectors)
```

```python
eigenvalues = np.load("eigenvalues.npy")
eigenvectors = np.load("eigenvectors.npy")
```

```python
# Ordered Laplacian eigenvalues. (square of graph frequencies)
e = eigenvalues

# Ordered graph Fourier basis. = Eigenvectors of Laplacian
U = eigenvectors#? https://epfl-lts2.github.io/gspbox-html/doc/utils/gsp_compute_fourier_basis_code.html
```

Plot the first 3 and the last Fourier basis vectors as signals on your graph. Clearly indicate which plot belongs to which basis vector.

```python
import pygsp as pg
graph = pg.graphs.Graph(adjacency)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
graph.set_coordinates('line1D')
graph.plot_signal(U[:, 1:4], ax=axes[0], plot_name='First 3 Fourier Basis Vector', colorbar=True)
legend = [r'$Basis Vec={}$'.format(t) for t in [1,2,3]]
axes[0].legend(legend)
graph.set_coordinates('line1D')
graph.plot_signal(U[:, -3:], ax=axes[1], plot_name='Last 3 Fourier Basis Vector' , colorbar=True)
legend = [r'$Basis Vec={}$'.format(t) for t in [-3,-2,-1]]
axes[1].legend(legend)
fig.tight_layout()
```

```python
low = min (np.ndarray.min(U[:,:3]), np.ndarray.min(U[:,-3:]))
up = max (np.ndarray.max(U[:,:3]), np.ndarray.max(U[:,-3:]))
limits=[low ,up]
print(limits)

#or
limits=[0.1 ,-0.1]
```

    [-0.10332255, 0.10556062]

```python
# Plot the First and Last 3 Fourier Basis Vectors is Laplacian Eigenmaps coordinates
graph.set_coordinates(eigenvectors[:, 1:3])  # Laplacian eigenmaps.
fig, axes = plt.subplots(2, 3, figsize=(20, 12))

# first 3
graph.plot_signal(U[:, 1], ax=axes[0,0],plot_name='First 1 Fourier Basis Vector', colorbar=True , limits=limits)
graph.plot_signal(U[:, 2], ax=axes[0,1],plot_name='First 2 Fourier Basis Vector', colorbar=True , limits=limits)
graph.plot_signal(U[:, 3], ax=axes[0,2],plot_name='First 3 Fourier Basis Vector', colorbar=True , limits=limits)

# last 3
graph.plot_signal(U[:, -3], ax=axes[1,0],plot_name='Last 3 Fourier Basis Vector', colorbar=True , limits=limits)
graph.plot_signal(U[:, -2], ax=axes[1,1],plot_name='Last 2 Fourier Basis Vector', colorbar=True , limits=limits)
graph.plot_signal(U[:, -1], ax=axes[1,2],plot_name='Last 1 Fourier Basis Vector', colorbar=True , limits=limits)
```

### Question 2

What can you observe in terms of local variations when comparing the basis vectors corresponding to the smallest eigenvalues to those corresponding to the largest eigenvalue? How would this justify the interpretation of the eigenvalues as "graph frequencies"?

**Our answer:**

For the eigenvectors corresponding to the smaller eigenvalues (first three) which are close to zero, we see smoother and slow oscillating functions. Therefore, these smallest eigenvalues corresponds to low frequencies, on the other hand for the biggest 3 eigenvectors that have the largest 3 eigenvalues, we see that oscilation is more faster. Therefore, we can say that the largest eigenvalue (which are far from zero value) have graph high frequencies.

 ### Question 3

Implement a function that returns the Graph Fourier Transform (GFT) of a given vector $x \in \mathbb{R}^{N},$ with respect to your graph, and a function that computes the corresponding inverse GFT (iGFT).

```python
def GFT(x):
    x_gft = np.tensordot(U, x, (0, 0))
    return x_gft

def iGFT(x):
    x_igft = np.tensordot(U, x, (1, 0))
    return x_igft

```

### Question 4

Plot your feature/label vector as a signal on your graph

```python
graph.set_coordinates(eigenvectors[:, 1:3])
graph.plot_signal(labels, plot_name='Label Vector as a Signal on Graph', colorbar=True, limits=limits)
```

```python
# white values are way higher than black ones (Nonspammers higher than spammers)
np.unique(labels,return_counts=True)
```

    (array([0, 1]), array([59725,  2448]))

Plot the absolute values of the GFT of your feature/label signal as a function of the graph eigenvalues. Make sure to add a marker indicating the position of each graph eigenvalue, and remember to properly name the axes.

```python
#graph.set_coordinates(eigenvectors[:, 1:3])
plt.plot(e, abs(GFT(labels)))
```

### Question 5

Discuss the behavior of the GFT that you plotted in the last question via comparing the plot of your label signal and those of the Fourier basis of Question 1. Would you consider your labels a "low-pass" or "high-pass" signal, or yet something else entirely?

```python
# Stem Plot
# We only plotted for first 5000 labels but the remaining maintains the same behaviour
plt.figure(figsize=(400,5))
plt.stem(labels[:5000])
```

In the last graph we draw our network with our labels signal, the values are either 1-black (spammers) or 0-white (non-spammers). Hence, there are more whites (non-spammers). We see this from the above stem plot of the signal as well. Once we look at to the abstract value of labels after GFT, we see that the last plot in question 4, result is smoother than the ones we see in the first question. Hence, we think that our labels signal is low pass.

**Your answer here.**

## 2 - Filtering on graphs

In this section we will check how filtered Dirac impulses diffuse on your graph.

### Question 6

Implement the following three filter kernels and the graph filtering operation.

- The **heat kernel** is supposed to take in a vector of eigenvalues `e` and a parameter `t` and output a vector of evaluations of the heat kernel at those eigenvalues (see the course slides for help).
- The **inverse filter** kernel is supposed to take in a vector of eigenvalues `e` and a parameter `t` and implement spectrally the  filter defined in the node domain by $f_{out}  = (I + t L)^{-1} f_{in},$ where $f_{in}, f_{out} \in \mathbb{R}^{N}$ are, repectively, the input and output signals to the filter.
- The **rectangle kernel** takes in a vector of eigenvalues `e` and parameters `l_min` and `l_max` and returns `1.0` at coordinates satisfying $(e[l] \geq l_{min}) \wedge (e[l] \leq l_{max}),$ and `0.0` otherwise.
- The **graph filtering** operation takes a graph signal $x \in \mathbb{R}^{N}$, a spectral graph `kernel` and a set of keyworded variables, and returns the corresponding filtered signal.
    - _Hint:_ Remember that you have implemented the `GFT` and `iGFT` operations in Question 3.
    - The `**kwargs` is a placeholder to collect supplementary pairs of keyword-values that are not known by the implementation before execution time.
      The `kwargs` variable is a dictionary whose keyes and values are the parameter names and values.
      This is useful to allow both `graph_filter(x, heat_kernel, tau=1.0)` and `graph_filter(x, rectangle_kernel, lambda_min=0.0, lambda_max=1.0)` to be valid calls from the same implementation.
      One can then defer the keyword-value assignment to the `kernel` call: `foo = kernel(bar, **kwargs)`.

```python
#http://www.robots.ox.ac.uk/~phst/BMVC2005/papers/58/bmvc05.pdf
def heat_kernel(e, t):
    return np.exp(-t*e)

#http://jelena.ece.cmu.edu/repository/conferences/14_GlobalSIP_ChenSMK.pdf
def inverse_kernel(e, t):
    return 1/(e*t+1)

def rectangle_kernel(e, l_min, l_max):
    return np.where(e>=l_min,1,0)*np.where(e<=l_max,1,0)

def graph_filter(x, kernel, **kwargs):
    return iGFT(sparse.spdiags(kernel(e,**kwargs),[0],e.size,e.size) @ GFT(x))
```

### Question 7

Plot all three filter kernels in the spectral domain. Remember to properly name the axes and title the plots. Choose filter parameters that best approximate the behavior of the GFT of your feature/label signal (as seen in Question 4).

```python

plt.subplot(3,1,1)
plt.title("Heat Filter Kernel Frequency Response")
plt.xlabel("Laplacian EigenValues")
plt.ylabel("Filter Response")
plt.plot(e, heat_kernel(e,10**2))

plt.subplot(3,1,2)
plt.title("Inverse Filter Kernel Frequency Response")
plt.xlabel("Laplacian EigenValues")
plt.ylabel("Filter Response")
plt.plot(e, inverse_kernel(e,10**3))

plt.subplot(3,1,3)
plt.title("Reactangle Filter Kernel Frequency Response")
plt.xlabel("Laplacian EigenValues")
plt.ylabel("Filter Response")
plt.plot(e, rectangle_kernel(e,-1,0.01))

plt.subplots_adjust(hspace=0.5,top=3)
```

### Question 8

Consider two Dirac impulses arbitrarily placed on your graph. Plot their filtered versions by the three filter kernels implemented in Question 6.

```python
num_nodes = 62173

dirac_1 = np.zeros(num_nodes)
dirac_2 = np.zeros(num_nodes)
a = np.random.randint(0,num_nodes)
b = np.random.randint(0,num_nodes)
dirac_1[a] = 1
dirac_2[b] = 1

plt.subplot(4,1,1)
plt.title("Delta Dirac Signal")
plt.xlabel("Signal Frequency")
plt.ylabel("Filter Response")
plt.plot(range(num_nodes), dirac_1+dirac_2)

plt.subplot(4,1,2)
plt.title("Delta Dirac Signal Filtered by Heat Kernel in Graph Frequency Domain")
plt.xlabel("Signal Frequency")
plt.ylabel("Filter Response")
plt.plot(range(num_nodes),graph_filter(dirac_1+dirac_2,heat_kernel,t=10**2))

plt.subplot(4,1,3)
plt.title("Delta Dirac Signal Filtered by Inverse Kernel in Graph Frequency Domain")
plt.xlabel("Signal Frequency")
plt.ylabel("Filter Response")
plt.plot(range(num_nodes),graph_filter(dirac_1+dirac_2,inverse_kernel,t=10**3))

plt.subplot(4,1,4)
plt.title("Spectral Decomposition of Delta Dirac Signal Filtered by Rectangle Kernel in Graph Frequency Domain")
plt.xlabel("Signal Frequency")
plt.ylabel("Filter Response")
plt.plot(range(num_nodes),graph_filter(dirac_1+dirac_2,rectangle_kernel,l_min=-1,l_max=0.01))

plt.subplots_adjust(hspace=0.5,top=3)

```

Comment on the "diffusion" of the Diracs induced by the filters. What does it say about the "communication" of information across your network? Relate that to the network connectivity measures that you analyzed during the previous milestones.

We notice that all three of our filters smooth out the spikes caused by or delta-dirac signals very nicely. This supports the notion that our network is well connected and thus robust : significant local pertubations are able to be elimited via fitering.

This concordes with last week's measure of Alegbraic Connectivity ( magnitude of the eigenvalue associated with the Fiedler Vector ) which was not only high, but also gave us an accurate partitioning -- the latter suggests that there are significant underlying structural properties in our graph, which would in turn imply that it would be resistant to local pertubations.

The robustness of our network is concordant with the way in which our original data-set was downsampled : starting around a highly connected node and then progressively adding neighbors.

## 3 - De-noising

In this section we will add some centered Gaussian noise to your feature/label signal and attempt to recover it.

### Question 9

In the cell below, set the noise variance $\sigma^2$ by making sure that the signal-to-noise ratio $SNR = \frac{\operatorname{Var}(\text{labels})}{\sigma^2}$ is about  $1.5$.

_Note:_ Actually, you might want to play with the noise variance here and set it to different values and see how the denoising filters behave.

```python
labels[labels <=0 ]   = -1
signal_to_noise_ratio = 1.5
noise_variance        = np.var(labels)/signal_to_noise_ratio
noisy_measurements    = labels - np.random.randn(n_nodes,1)*noise_variance
```

### Question 10

In the denoising setting, a common graph signal processing assumption is that the signal $z$ that we want to recover is "smooth", in the sense that $\|\nabla_G z\|_2 = \sqrt{z^{\top} L z}$ is small, while remaining "close" to the measurements that we start with. This leads to denoising by solving the following optimization problem:

$$
z^\star = \text{arg} \, \underset{z \in \mathbb{R}^{N}}{\min} \, \|z - y\|_2^2 + \gamma z^{\top} L z,
$$

where $y \in \mathbb{R}^{N}$ is the vector of noisy measurements.

Derive the close form solution to this problem giving $z^\star$ as a function of $y$, $\gamma$ and $L$. Does this solution correspond to any graph filtering operation that you know?

$$
\begin{align}
& z = z^\star \\
\equiv & \space  \frac{\partial z^\star }{\partial z} = 0 \\
\equiv & \space z-y + \gamma L z = 0                     \\
\equiv & \space I-yz^{-1} + \gamma L =0                  \\
\equiv & \space yz^{-1} = I + \gamma L                   \\
\equiv & \space y(I + \gamma L)^{-1} = z
\end{align}
$$

The closed form solution above corresponds to the inverse filtering operation

### Question 11

Now, denoise the noisy measurements by passing them through the filters that you implemented in Question 6. Choose the filter parameters based on the behavior of the GFT of your original label signal (this is the prior knowledge that you input to the problem).

```python
t_heat_denoised=10**2
t_inv_denoised=10**3
l_min=-1
l_max=0.01

z_heat_denoised = graph_filter(noisy_measurements,heat_kernel,t=10**2)
z_inv_denoised  = graph_filter(noisy_measurements,inverse_kernel,t=10**3)
z_rect_denoised = graph_filter(noisy_measurements,rectangle_kernel, l_min=-1, l_max=0.01)

```

    '\nz_heat_denoised[z_heat_denoised >= 0] =  1\nz_heat_denoised[z_heat_denoised <  0] = -1 \nz_inv_denoised[z_inv_denoised >= 0]   =  1\nz_inv_denoised[z_inv_denoised <  0]   = -1 \nz_rect_denoised[z_rect_denoised >= 0] =  1\nz_rect_denoised[z_rect_denoised <  0] = -1 \n'

Plot, on your graph, the original label signal, the noisy measurements, and the three denoised version obtained above. Report on each plot the value of the corresponding relative error
$$
\text{rel-err} = \frac{\|\text{labels} - z \|_2}{\|\text{labels}\|_2},
$$
where $z$ is the plotted signal.

```python
plt.scatter(U[:, 1], U[:, 2], c=labels.reshape(num_nodes), alpha=0.5)
plt.title('Original Label Signal')
```

1) Noised signal

```python
rel_err_noised=np.linalg.norm(labels-noisy_measurements)/np.linalg.norm(labels)
plt.scatter(U[:, 1], U[:, 2], c=noisy_measurements.reshape(num_nodes), alpha=0.5)
plt.title('Relative Error of Noised Signal: %1.2f' %rel_err_noised)
print('The relative error of the noised signal is: ', rel_err_noised)
```

2) Heat kernel denoised signal

```python
rel_err_heat_denoised=np.linalg.norm(labels-z_heat_denoised)/np.linalg.norm(labels)
plt.scatter(U[:, 1], U[:, 2], c=z_heat_denoised.reshape(num_nodes), alpha=0.5)
plt.title('Relative Error of Heat Kernel Denoised Signal: %1.2f' %rel_err_heat_denoised)
print('The relative error of the heat kernel denoised signal is: ', rel_err_heat_denoised)
```

3) Inverse filter kernel denoised signal

```python
rel_err_inv_denoised=np.linalg.norm(labels-z_inv_denoised)/np.linalg.norm(labels)
plt.scatter(U[:, 1], U[:, 2], c=z_inv_denoised.reshape(num_nodes), alpha=0.5)
plt.title('Relative Error of Inverse Filter Kernel Denoised Signal: %1.2f' %rel_err_inv_denoised)
print('The relative error of the inverse filter kernel denoised signal is: ', rel_err_inv_denoised)
```

4) Rectangle kernel denoised signal

```python
rel_err_rect_denoised=np.linalg.norm(labels-z_rect_denoised)/np.linalg.norm(labels)
plt.scatter(U[:, 1], U[:, 2], c=z_rect_denoised.reshape(num_nodes), alpha=0.5)
plt.title('Relative Error of Rectangle Kernel Denoised Signal: %1.2f' %rel_err_rect_denoised)
print('The relative error of the rectangle kernel denoised signal is: ', rel_err_rect_denoised)
```

Finally, overlay on the same plot the GFT of all five signals above.

```python
overlay_labels=GFT(labels)
overlay_noisy_measurements=GFT(noisy_measurements)
overlay_heat_denoised=GFT(z_heat_denoised)
overlay_inv_denoised=GFT(z_inv_denoised)
overlay_rect_denoised=GFT(z_rect_denoised)

titles     =["Ground Truth","Noisy Labels","Denoised by Heat Kernel",
             "Denoised by Inv Kernel", "Denoised by Rectangle Kernel"]

overlay_all=[np.absolute(overlay_labels), np.absolute(overlay_noisy_measurements), np.absolute(overlay_heat_denoised), np.absolute(overlay_inv_denoised), np.absolute(overlay_rect_denoised)]
```

```python
for i,plot in enumerate(overlay_all):
    line, = plt.semilogy(plot)
    line.set_label(titles[i])
plt.title("Graph Fourier Transform of Signals")
plt.ylabel("GFT(x[i])")
plt.xlabel("i")
plt.legend(loc='lower center')
```

### Question 12

Comment on which denoised version seems to best match the original label signal. What is the underlying assumption behind the three filtering approaches? Do you think it holds for your label signal? Why?

*The denoised version with rectangle kernel seems to best match the original label signal as it has the least relative error estimation with the original signal compared to other denoised versions.*

## 4 - Transductive learning

It is often the case in large networks that we can only afford to query properties/labels on a small subset of nodes. Nonetheless, if the underlying labels signal is "regular" enough, we might still be able to recover a good approximation of it by solving an offline variational problem, with constraints on the values of the measured nodes.

In this section, we will be interested in solving such transductive learning problems by minimizing a (semi-) p-norm of the graph gradient applied to the signal of interest:

$$
\text{arg} \, \underset{z|_S = y}{\min} \|\nabla_G z\|_p^p,
$$

where $S$ is the set of measured nodes.

In English, we can say that we are looking for solutions with small "aggregated local variations", as measured by $\|\nabla_G z\|_p^p = \sum_{i=1}^{n} \sum_{j=1}^{n} \left( \sqrt{W_{ij}} |z[i] - z[j]| \right)^p,$ while satisfying the measurement constraints $z[i] = y[i]$ for $i \in S.$

We will work with two cases, according to the choices $p=1$ or $p=2.$ For $p=1,$ the problem is known as "interpolation by graph total-variation minimization," whereas for $p=2$ it is sometimes called "interpolation by Tikhonov regularization".

In order to solve these variational problems with the black-box solver provided to you, you will use the [pyunlocbox](https://pyunlocbox.readthedocs.io). This toolbox implements iterative solvers based on so-called ["proximal-splitting"](https://en.wikipedia.org/wiki/Proximal_gradient_method) methods.

### Question 13

Throughout this section, we will consider only a binarized version of your label signal. If your variable `labels` currently has values other than $\{-1, 1\},$ threshold them so that those are the only values taken in this vector. This can be done for example by choosing a number $t \in \mathbb{R}$ and then setting $\text{labels_bin}[i] = 1$ if $\text{labels}[i] \geq t$ and $\text{labels_bin}[i] = 0$ otherwise.

```python
#Drop Unnamed Column
labels_bin =  features.drop(columns=["Unnamed: 0"]).copy()
labels_bin = labels_bin["Spammer Label"].copy()
labels_bin.replace(0, -1, inplace = True)
```

Now, subsample this binarized label signal by $70\%$ by choosing, uniformly at random, $30\%$ of the nodes whose labels we will keep.

You will do this by computing a "measurement mask" vector `w` with `1.0`'s at the measured coordinates, and $0.0$'s otherwise.

```python
#We are going to make  our dataset smaller(10000 x 10000) as it takes too much time to compute Gradient to phase_transition Test.
new_adjacency = adjacency_org[:10000, :10000]
new_labels_bin = labels_bin.copy().head(10000)
```

```python
n_nodes = labels_bin.shape[0]

mn_ratio = 0.3
m = int(mn_ratio * n_nodes)  # Number of measurements.

#Mask for Original Adjacency Matrix
w = np.zeros(len(labels_bin))
new_index= np.random.choice(range(len(labels_bin)), m, replace=False)
for i in range(len(new_index)):
    w[new_index[i]]=1

#Mask for 10000x10000 Adjacency Matrix
new_w = np.zeros(len(new_labels_bin))
new_m = int(mn_ratio * len(new_labels_bin)) #Number of Measuerment for new_adj
new_index= np.random.choice(range(len(new_labels_bin)), new_m, replace=False)
for i in range(len(new_index)):
    new_w[new_index[i]]=1
```

Plot the subsampled signal on the graph. _Hint:_ you might want to set to `numpy.nan` the values of the un-measured nodes for a cleaner plot.

```python
# Your code here.
plot_w = np.where(w == 1.0, w, np.nan)
plt.scatter(U[:, 1], U[:, 2], c=plot_w, alpha=0.5)
plt.title('Plot of Subsampled Signal')
```

### Interlude

For the solution of the variational problems you can use the following function as a "black-box".

You will just need to provide a `gradient` matrix (which you should already have from Section 0), and an orthogonal projection operator `P` onto the span of the measured coordinates (made precise in the next question).

```python
def graph_pnorm_interpolation(gradient, P, x0=None, p=1., **kwargs):
    r"""
    Solve an interpolation problem via gradient p-norm minimization.

    A signal :math:`x` is estimated from its measurements :math:`y = A(x)` by solving
    :math:`\text{arg}\underset{z \in \mathbb{R}^n}{\min}
    \| \nabla_G z \|_p^p \text{ subject to } Az = y`
    via a primal-dual, forward-backward-forward algorithm.

    Parameters
    ----------
    gradient : array_like
        A matrix representing the graph gradient operator
    P : callable
        Orthogonal projection operator mapping points in :math:`z \in \mathbb{R}^n`
        onto the set satisfying :math:`A P(z) = A z`.
    x0 : array_like, optional
        Initial point of the iteration. Must be of dimension n.
        (Default is `numpy.random.randn(n)`)
    p : {1., 2.}
    kwargs :
        Additional solver parameters, such as maximum number of iterations
        (maxit), relative tolerance on the objective (rtol), and verbosity
        level (verbosity). See :func:`pyunlocbox.solvers.solve` for the full
        list of options.

    Returns
    -------
    x : array_like
        The solution to the optimization problem.

    """

    grad = lambda z: gradient.dot(z)
    div = lambda z: gradient.transpose().dot(z)

    # Indicator function of the set satisfying :math:`y = A(z)`
    f = functions.func()
    f._eval = lambda z: 0
    f._prox = lambda z, gamma: P(z)

    # :math:`\ell_1` norm of the dual variable :math:`d = \nabla_G z`
    g = functions.func()
    g._eval = lambda z: np.sum(np.abs(grad(z)))
    g._prox = lambda d, gamma: functions._soft_threshold(d, gamma)

    # :math:`\ell_2` norm of the gradient (for the smooth case)
    h = functions.norm_l2(A=grad, At=div)

    stepsize = (0.9 / (1. + scipy.sparse.linalg.norm(gradient, ord='fro'))) ** p

    solver = solvers.mlfbf(L=grad, Lt=div, step=stepsize)

    if p == 1.:
        problem = solvers.solve([f, g, functions.dummy()], x0=x0, solver=solver, **kwargs)
        return problem['sol']
    if p == 2.:
        problem = solvers.solve([f, functions.dummy(), h], x0=x0, solver=solver, **kwargs)
        return problem['sol']
    else:
        return x0
```

### Question 14

During the iterations of the algorithm used for solving the variational problem, we have to make sure that the labels at the measured nodes stay the same. We will do this by means of an operator `P` which, given a vector $a \in \mathbb{R}^{N},$ returns another vector $b \in \mathbb{R}^{N}$ satisfying $b[i] = \text{labels_bin}[i]$ for every node $i$ in the set $S$ of known labels, and $b[i] = a[i]$ otherwise. Write in the cell below the function for this orthogonal projection operator `P`.

_Hint:_ remember you have already computed the mask `w`.

```python
def P(a):
    b = np.zeros(len(a))
    new_w
    for i in range(len(a)):
        if new_w[i]==0:
            b[i]=a[i]
        else:
            b[i]=new_labels_bin[i]
    return b
```

### Question 15

Solve the variational problems for $p = 1$ and $p = 2$. Record the solution for the $1-$norm minimization under `sol_1norm_min` and the one for $2-$norm minimization under `sol_2norm_min`.

Compute also binarized versions of these solutions by thresholding the values with respect to $0$, that is, non-negative values become `1.0`, while negative values become `-1.0`. Store those binarized versions under `sol_1norm_bin` and `sol_2norm_bin`, respectively.

```python
#Create Gradient for 10000 x 10000 adjacency metrix due runtime

degrees = new_adjacency.sum(0)
n_edges = np.sum(new_adjacency)
gradient = np.zeros((n_edges, new_adjacency.shape[0]))
c = 0
n_nodes = new_adjacency.shape[0]
for i in range(0, new_adjacency.shape[0]):
    for j in range(i+1, new_adjacency.shape[0]):
        if(new_adjacency[i][j] == 1):
            gradient[c][i] = 1
            gradient[c][j] = -1
            c+=1

gradientT = gradient.transpose()

lap  =  np.diag(degrees) - new_adjacency
new_lap = gradientT @ gradient
np.testing.assert_allclose((new_lap), lap)
```

```python
gradient = scipy.sparse.csr_matrix(gradient)
x0 = np.random.randn(n_nodes)

sol_1norm_min =  graph_pnorm_interpolation(gradient, P, x0 = np.random.randn(n_nodes), p=1.)

sol_2norm_min =  graph_pnorm_interpolation(gradient, P, x0 = np.random.randn(n_nodes), p=2.)

threshold = 0

sol_1norm_bin = np.copy(sol_1norm_min)
sol_2norm_bin = np.copy(sol_2norm_min)

np.place(sol_1norm_bin, sol_1norm_bin <= threshold, -1)
np.place(sol_1norm_bin, sol_1norm_bin > threshold, 1)

np.place(sol_2norm_bin, sol_2norm_bin <= threshold, -1)
np.place(sol_2norm_bin, sol_2norm_bin > threshold, 1)

```

    Solution found after 98 iterations:
        objective function f(sol) = 1.567224e+03
        stopping criterion: RTOL
    Solution found after 29 iterations:
        objective function f(sol) = 2.880657e+03
        stopping criterion: RTOL

Plot, on your graph, the original `labels_bin` signal, as well as the solutions to the variational problems (both binarized and otherwise). Indicate on each plot the value of the relative error $\text{rel-err} = \frac{\|\text{labels_bin} - z\|_2}{\|\text{labels_bin}\|_2}$, where $z$ is the signal in the corresponding plot.

```python
rel_err_min_1norm = np.linalg.norm(new_labels_bin - sol_1norm_min) / np.linalg.norm(new_labels_bin)
rel_err_min_2norm = np.linalg.norm(new_labels_bin - sol_2norm_min) / np.linalg.norm(new_labels_bin)
rel_err_bin_1norm = np.linalg.norm(new_labels_bin - sol_1norm_bin) / np.linalg.norm(new_labels_bin)
rel_err_bin_2norm = np.linalg.norm(new_labels_bin - sol_2norm_bin) / np.linalg.norm(new_labels_bin)

titles = [ "Ground Truth",
           "Solution to non-binarized Problem (p=1): relative_error="+str(rel_err_min_1norm),
           "Solution to non-binarized Problem (p=2): relative_error="+str(rel_err_min_2norm),
           "Solution to binarized Problem (p=1): relative_error="+str(rel_err_bin_1norm),
           "Solution to binarized Problem (p=2): relative_error="+str(rel_err_bin_2norm),
        ]

fig, axes = plt.subplots(nrows=5, ncols=1, figsize=(15,10))
for i,plot in enumerate([new_labels_bin,sol_1norm_min,
                         sol_2norm_min,sol_1norm_bin,
                         sol_2norm_bin,]):
    plt.subplot(5,1,i+1)
    plt.scatter(U[:10000, 1], U[:10000, 2], c=plot, alpha=0.5)
    plt.title(titles[i])

plt.subplots_adjust(hspace=1,top=1)

```

The above graphs represent each signal as a coloring of points, with each point being placed according to the 2nd and 3rd coordinates of the respective graph eigenvector.

### Question 16

Now that you have got a feeling for the sort of solutions that the transductive learning problems studied can give, we will see what is the effect of the number of measurements on the accuracy of both $p-$norm minimization problems.

Towards this goal, you will write a `phase_transition()` function. This function will basically go over all the procedures that you have implemented in this section, but for varying numbers of measurements and thresholding values. It will also compute the relative error, $\text{rel-err},$ of the solutions and average them over a number of trials.

The output of the `phase_transition()` function has to be a matrix with `len(mn_ratios)` columns and `len(thresholds)` rows. Each pixel $(i,j)$ in the output matrix has to contain the average, over `n_trials` trials, of the relative error $\text{rel-err}$ in the binarized (with threshold `thresholds[i]`) solution given by `graph_pnorm_interpolation()` from observing an `mn_ratios[j]` fraction of nodes. The randomness comes from a different choice of mask `w` at each trial, hence the averaging.

The interest of this phase transition matrix is to assess what level of recovery error one could expect for a certain fraction of measurements and a certain threshold level.

```python
def phase_transition(mn_ratios, thresholds, n_trials, labels_bin, p):
    pt_matrix = np.zeros((len(thresholds), len(mn_ratios)))
    for i in range(0, len(thresholds)):
        for j in range(0, len(mn_ratios)):
            errs = []
            for k in range(0, n_trials):

                # Create sample mask.

                new_w = np.zeros(len(labels_bin))
                new_m = int(mn_ratios[j] * len(labels_bin))
                new_index= np.random.choice(range(len(labels_bin)), new_m, replace=False)
                for q in range(len(new_index)):
                    new_w[new_index[q]]=1

                # Solve p-norm interpolation.

                solution = graph_pnorm_interpolation(gradient, P, x0 = np.random.randn(n_nodes), p = p,\
                                                     maxit=15,verbosity="NONE")

                #Binarize Solution by threshold
                np.place(solution, solution <= thresholds[i], -1)
                np.place(solution, solution > thresholds[i], 1)

                #Calculate Relative Error
                err = np.linalg.norm(labels_bin - solution) / np.linalg.norm(labels_bin)

                #Append Relative Error
                errs.append(err)

            #Aggregate
            avg = np.sum(errs)/len(errs)
            pt_matrix[i][j] = avg

    return pt_matrix
```

### Question 17

Pick 5 "m/n" ratios in $(0, 1)$ and 5 threshold levels in $(-1, 1)$ and run the `phase_transition()` function with `n_trials` = 20, for both $p = 1$ and $p = 2$.

```python
mn_ratios  = [0.3, 0.5, 0.7, 0.8, 0.9]
thresholds = [1, 0.5, 0.0, -0.2 , -1]
pt_matrix_2norm = phase_transition(mn_ratios, thresholds, 20, new_labels_bin, p=2)
pt_matrix_1norm = phase_transition(mn_ratios, thresholds, 20, new_labels_bin, p=1)
```

```python
X, Y = np.meshgrid(mn_ratios, thresholds)
Z    = pt_matrix_1norm

fig, (ax0) = plt.subplots(1, 1)
c = ax0.pcolormesh(X, Y, Z)
fig.colorbar(c, ax=ax0)

plt.ylabel("Threshold")
plt.xlabel("Ratio")
plt.title("Relative Error as a Function of Thresholding and Ratio with p=1.")
```

```python
X, Y = np.meshgrid(mn_ratios, thresholds)
Z    = pt_matrix_2norm

fig, (ax0) = plt.subplots(1, 1)
c = ax0.pcolormesh(X, Y, Z)
fig.colorbar(c, ax=ax0)
plt.ylabel("Threshold")
plt.xlabel("Ratio")
plt.title("Relative Error as a Function of Thresholding and Ratio with p=2.")
```

Plot both phase transition matrices as images with a colorbar. Make sure to properly name the axes and title the images.

### Question 18

Do the phase transition plots above provide any justification for choosing one $p-$norm interpolation over the other? Why?

The error in our case mostly does not make sense and this due to the dataset. The fact is that mostly the features that we have do not affect if somebody is a spammer or not. I guess in the real world being a spammer or not would depend on how many outgoing messages a user has proportinal on how many the user has received. The bigger this number gets the more indiciation we would have if somebody is a spammer or not.

Since this is not an actual feature of our dataset and most of our users are not labeled as spammer, the best error that we get is when out threshold is set to 1, meaning that all the users will be labeled as non-spammers. This actually makes sense. And obviously as the threshold goes less and less we get less and less accuracy, as we trying to learn something that does not have any sense and correlation.

The error in our case it mostly make sense and this due to the dataset. The original dataset that we were given and we we are going to work on for the project is actually directed. So in the directed dataset it would make a huge difference, the proportion of how many messages a user has sent and how many messages a user has received. Since we are working with the undirected adjacency we are negating this factor. The reason why we get this results like having 1 as threshold level and why it gives us the biggest accuracy, is that most of the users are being labeled as non-spammers. So when setting the threshold to 1, it means that all results will be set to being non-spammers, thus having the best average accuracy. As the threshold decreases we get worse and worse results as we are not differentiating between undirected and directed, thus destroying the correlation of being identified as a spammer or not.

Another factor would be that we are not working with the whole-dataset, we actually shorten up the adjacency matrix to 10000 x 10000 due to runtime, so for example if node 0 is being labeled as a spammer and it does not have any connection or a small number of connections to node 1 to 10000, but it has a lot of connections to node 10001 to 62137 than. This subsampling destroys the actual correlation and thus we are getting this results.

To sum it up, subsampling the dataset and working with the unadjcacency matrix makes it almost impossible for the solver to get us something with logical meaning, as the original labels are based on a bigger dataset and directed adjacency matrix.

The subsampling was necessary and was done due to the runtime.

About the ratios, we can see that the average changes slightly but not much. This is due to the fact that again there is not much of a correlation.

In real world in ML both thresholds and rations have a huge affect and usually the best threshold would be 50%-50% in our case 0.

```python

```


```python
import discretize
from discretize import utils
import numpy as np
import scipy.sparse as sp
import sympy
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
from scipy.constants import mu_0, inch, foot

from SimPEG.EM import TDEM
from SimPEG import Utils, Maps

import casingSimulations

from pymatsolver import Pardiso

%matplotlib inline
```

```python
simDir = 'example'
```

```python
casingModel = casingSimulations.model.CasingInHalfspace(
    directory=simDir,
    sigma_back=1,
    mur_back=1,
    src_a=np.r_[0.,np.pi,0.],
    src_b=np.r_[1e3, np.pi, 0.]
)
```

```python
casingModel.serialize()
```

    {u'__class__': 'CasingInHalfspace',
     'casing_d': 0.1,
     'casing_l': 1000.0,
     'casing_t': 0.01,
     'casing_top': 0.0,
     'directory': u'example',
     'filename': u'ModelParameters.json',
     'mur_back': 1.0,
     'mur_casing': 100.0,
     'sigma_air': 1e-06,
     'sigma_back': 1.0,
     'sigma_casing': 5500000.0,
     'sigma_inside': 1.0,
     'src_a': [0.0, 3.141592653589793, 0.0],
     'src_b': [1000.0, 3.141592653589793, 0.0],
     'surface_z': 0.0,
     'version': u'0.0.2'}

```python
wholespace.save()
```

    Saved example/ModelParameters.json

```python
cp=casingSimulations.load_properties('/'.join([simDir, 'ModelParameters.json']), targetModule=casingSimulations.model)
```

```python
npadx, npadz = 9, 18
dx2 = 200.
csz = 0.5

meshGen2D = casingSimulations.mesh.CasingMeshGenerator(
    cp=casingModel, npadx=npadx, npadz=npadz, domain_x2=dx2, csz=csz
)
mesh2D = meshGen2D.mesh

print(mesh2D.vectorNx.max(), mesh2D.vectorNz.min(), mesh2D.vectorNz.max())
```

    (3108.251953125, -3217.8378200531006, 2217.8378200531006)

```python
mesh2D.plotGrid()
```

```python
ncy = 1
nstretchy = 3
stretchfact = 1.6
hy = utils.meshTensor([(1, nstretchy, -stretchfact), (1, ncy), (1, nstretchy, stretchfact)])
hy = hy * 2*np.pi/hy.sum()
```

```python
meshGen3D = casingSimulations.CasingMeshGenerator(
    cp=casingModel, npadx=npadx, npadz=npadz, domain_x2=dx2, hy=hy, csz=csz
)
```

```python
meshGen3D.mesh.plotGrid()
```

```python
meshGen3D.directory = simDir
meshGen3D.serialize()
```

    {u'__class__': 'CasingMeshGenerator',
     'cp': {u'__class__': 'CasingInHalfspace',
      'casing_d': 0.1,
      'casing_l': 1000.0,
      'casing_t': 0.01,
      'casing_top': 0.0,
      'directory': u'example',
      'filename': u'ModelParameters.json',
      'mur_back': 1.0,
      'mur_casing': 100.0,
      'sigma_air': 1e-06,
      'sigma_back': 1.0,
      'sigma_casing': 5500000.0,
      'sigma_inside': 1.0,
      'src_a': [0.0, 0.0, 0.0],
      'src_b': [1000.0, 3.141592653589793, 0.0],
      'surface_z': 0.0,
      'version': u'0.0.2'},
     'csx1': 0.0025,
     'csx2': 25.0,
     'csz': 0.5,
     'directory': u'example',
     'domain_x2': 200.0,
     'filename': u'MeshParameters.json',
     'hy': [1.4696166638994737,
      0.918510414937171,
      0.5740690093357318,
      0.3587931308348324,
      0.5740690093357318,
      0.918510414937171,
      1.4696166638994737],
     'nca': 5,
     'ncb': 5,
     'npadx': 9,
     'npadz': 18,
     'pfx1': 1.3,
     'pfx2': 1.5,
     'pfz': 1.5,
     'version': u'0.0.2'}

```python
physprops = casingSimulations.model.PhysicalProperties(meshGen3D, casingModel)
```

```python
ax = physprops.plot_sigma()
ax.set_xlim([-0.5, 0.5])
ax.set_ylim([-1500., 0.])
```

```python
casingModel.timeSteps = [(1e-6, 10), (1e-5, 10), (1e-4, 10)]
```

```python
src = casingSimulations.sources.TopCasingSrc?
```

```python
src = casingSimulations.sources.TopCasingSrc(
    cp=casingModel, directory=simDir, meshGenerator=meshGen3D
)
```

```python
src.plot()
```

```python
sim = casingSimulations.run.SimulationTDEM(cp=casingModel, meshGenerator=meshGen3D, directory=simDir, src=src)
```

```python
sim.survey.prob
```

    <SimPEG.EM.Static.DC.ProblemDC.Problem3D_CC at 0x124f7e150>

```python
sim.validate()
```

    True

```python
sim.write_py()
```

    Saved example/ModelParameters.json
    Saved example/MeshParameters.json
    Saved example/Source.json
    wrote example/simulation.py

```python

```


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

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

___
# Tutorial Objectives
This is tutorial 2 of a 3-part series on different flavors of models used to understand neural data. In this tutorial we will explore models that can potentially explain *how* the spiking data we have observed is produced

To understand the mechanisms that give rise to the neural data we save in Tutorial 1,  we will build simple neuronal models and compare their spiking response to real data. We will:
- Write code to simulate a  simple "leaky integrate-and-fire" neuron model
- Make the model more complicated — but also more realistic — by adding more physiologically-inspired details

```python
#@title Video 1: "How" models
from IPython.display import IFrame
class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
        self.id=id
        src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
        super(BiliVideo, self).__init__(src, width, height, **kwargs)

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

    Video available at https://www.bilibili.com/video/BV1yV41167Di

# Setup

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

```python
#@title Figure Settings
import ipywidgets as widgets #interactive display

%matplotlib inline
%config InlineBackend.figure_format = 'retina'
plt.style.use("/share/dataset/COMMON/nma.mplstyle.txt")

```

```python
#@title Helper Functions
def histogram(counts, bins, vlines=(), ax=None, ax_args=None, **kwargs):
  """Plot a step histogram given counts over bins."""
  if ax is None:
    _, ax = plt.subplots()

  # duplicate the first element of `counts` to match bin edges
  counts = np.insert(counts, 0, counts[0])

  ax.fill_between(bins, counts, step="pre", alpha=0.4, **kwargs)  # area shading
  ax.plot(bins, counts, drawstyle="steps", **kwargs)  # lines

  for x in vlines:
    ax.axvline(x, color='r', linestyle='dotted')  # vertical line

  if ax_args is None:
    ax_args = {}

  # heuristically set max y to leave a bit of room
  ymin, ymax = ax_args.get('ylim', [None, None])
  if ymax is None:
    ymax = np.max(counts)
    if ax_args.get('yscale', 'linear') == 'log':
      ymax *= 1.5
    else:
      ymax *= 1.1
      if ymin is None:
        ymin = 0

  if ymax == ymin:
    ymax = None

  ax_args['ylim'] = [ymin, ymax]

  ax.set(**ax_args)
  ax.autoscale(enable=False, axis='x', tight=True)

def plot_neuron_stats(v, spike_times):
  fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(12, 5))

  # membrane voltage trace
  ax1.plot(v[0:100])
  ax1.set(xlabel='Time', ylabel='Voltage')
  # plot spike events
  for x in spike_times:
    if x >= 100:
      break
    ax1.axvline(x, color='red')

  # ISI distribution
  isi = np.diff(spike_times)
  n_bins = np.arange(isi.min(), isi.max() + 2) - .5
  counts, bins = np.histogram(isi, n_bins)
  vlines = []
  if len(isi) > 0:
    vlines = [np.mean(isi)]
  xmax = max(20, int(bins[-1])+5)
  histogram(counts, bins, vlines=vlines, ax=ax2, ax_args={
    'xlabel': 'Inter-spike interval',
    'ylabel': 'Number of intervals',
    'xlim': [0, xmax]
  })
  plt.show()
```

# Section 1: The Linear Integrate-and-Fire Neuron

How does a neuron spike?

A neuron charges and discharges an electric field across its cell membrane. The state of this electric field can be described by the _membrane potential_. The membrane potential rises due to excitation of the neuron, and when it reaches a threshold a spike occurs. The potential resets, and must rise to a threshold again before the next spike occurs.

One of the simplest models of spiking neuron behavior is the linear integrate-and-fire model neuron. In this model, the neuron increases its membrane potential $V_m$ over time in response to excitatory input currents $I$ scaled by some factor $\alpha$:

\begin{align}
  dV_m = {\alpha}I
\end{align}

Once $V_m$ reaches a threshold value a spike is produced, $V_m$ is reset to a starting value, and the process continues.

Here, we will take the starting and threshold potentials as $0$ and $1$, respectively. So, for example, if $\alpha I=0.1$ is constant---that is, the input current is constant---then $dV_m=0.1$, and at each timestep the membrane potential $V_m$ increases by $0.1$ until after $(1-0)/0.1 = 10$ timesteps it reaches the threshold and resets to $V_m=0$, and so on.

Note that we define the membrane potential $V_m$ as a scalar: a single real (or floating point) number. However, a biological neuron's membrane potential will not be exactly constant at all points on its cell membrane at a given time. We could capture this variation with a more complex model (e.g. with more numbers). Do we need to?

The proposed model is a 1D simplification. There are many details we could add to it, to preserve different parts of the complex structure and dynamics of a real neuron. If we were interested in small or local changes in the membrane potential, our 1D simplification could be a problem. However, we'll assume an idealized "point" neuron model for our current purpose.

#### Spiking Inputs

Given our simplified model for the neuron dynamics, we still need to consider what form the input $I$ will take. How should we specify the firing behavior of the presynaptic neuron(s) providing the inputs to our model neuron?

Unlike in the simple example above, where $\alpha I=0.1$, the input current is generally not constant. Physical inputs tend to vary with time. We can describe this variation with a distribution.

We'll assume the input current $I$ over a timestep is due to equal contributions from a non-negative ($\ge 0$) integer number of input spikes arriving in that timestep. Our model neuron might integrate currents from 3 input spikes in one timestep, and 7 spikes in the next timestep. We should see similar behavior when sampling from our distribution.

Given no other information about the input neurons, we will also assume that the distribution has a mean (i.e. mean rate, or number of spikes received per timestep), and that the spiking events of the input neuron(s) are independent in time. Are these reasonable assumptions in the context of real neurons?

A suitable distribution given these assumptions is the Poisson distribution, which we'll use to model $I$:

\begin{align}
  I \sim \mathrm{Poisson}(\lambda)
\end{align}

where $\lambda$ is the mean of the distribution: the average rate of spikes received per timestep.

### Exercise 1: Compute $dV_m$

For your first exercise, you will write the code to compute the change in voltage $dV_m$ (per timestep) of the linear integrate-and-fire model neuron. The rest of the code to handle numerical integration is provided for you, so you just need to fill in a definition for `dv` in the `lif_neuron` function below. The value of $\lambda$ for the Poisson random variable is given by the function argument `rate`.

The [`scipy.stats`](https://docs.scipy.org/doc/scipy/reference/stats.html) package is a great resource for working with and sampling from various probability distributions. We will use the `scipy.stats.poisson` class and its method `rvs` to produce Poisson-distributed random samples. In this tutorial, we have imported this package with the alias `stats`, so you should refer to it in your code as `stats.poisson`.

```python
def lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  """ Simulate a linear integrate-and-fire neuron.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    rate (int): The mean rate of incoming spikes

  """
  # precompute Poisson samples for speed
  exc = stats.poisson(rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []

  ################################################################################
  # Students: compute dv, then comment out or remove the next line
  raise NotImplementedError("Excercise: compute the change in membrane potential")
  ################################################################################

  for i in range(1, n_steps):

    dv = ...

    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

# Uncomment these lines after completing the lif_neuron function
# v, spike_times = lif_neuron()
# plot_neuron_stats(v, spike_times)
```

```python
# to_remove solution
def lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  """ Simulate a linear integrate-and-fire neuron.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    rate (int): The mean rate of incoming spikes
  """
  # precompute Poisson samples for speed
  exc = stats.poisson(rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []
  for i in range(1, n_steps):

    dv = alpha * exc[i]

    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

v, spike_times = lif_neuron()
with plt.xkcd():
  plot_neuron_stats(v, spike_times)
```

## Interactive Demo: Linear-IF neuron
Like last time, you can now explore how various parametes of the LIF model influence the ISI distribution.

```python
#@title

#@markdown You don't need to worry about how the code works – but you do need to **run the cell** to enable the sliders.

def _lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  exc = stats.poisson(rate).rvs(n_steps)
  v = np.zeros(n_steps)
  spike_times = []
  for i in range(1, n_steps):
    dv = alpha * exc[i]
    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0
  return v, spike_times

@widgets.interact(
  n_steps=widgets.FloatLogSlider(1000.0, min=2, max=4),
  alpha=widgets.FloatLogSlider(0.01, min=-2, max=-1),
  rate=widgets.IntSlider(10, min=5, max=20)
)
def plot_lif_neuron(n_steps=1000, alpha=0.01, rate=10):
  v, spike_times = _lif_neuron(int(n_steps), alpha, rate)
  plot_neuron_stats(v, spike_times)
```

```python
#@title Video 2: Linear-IF models
from IPython.display import IFrame
class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
        self.id=id
        src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
        super(BiliVideo, self).__init__(src, width, height, **kwargs)

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

    Video available at https://www.bilibili.com/video/BV1iZ4y1u7en

# Section 2: Inhibitory signals

Our linear integrate-and-fire neuron from the previous section was indeed able to produce spikes. However, our ISI histogram doesn't look much like empirical ISI histograms seen in Tutorial 1, which had an exponential-like shape. What is our model neuron missing, given that it doesn't behave like a real neuron?

In the previous model we only considered excitatory behavior -- the only way the membrane potential could decrease was upon a spike event. We know, however, that there are other factors that can drive $V_m$ down. First is the natural tendency of the neuron to return to some steady state or resting potential. We can update our previous model as follows:

\begin{align}
  dV_m = -{\beta}V_m + {\alpha}I
\end{align}

where $V_m$ is the current membrane potential and $\beta$ is some leakage factor. This is a basic form of the popular Leaky Integrate-and-Fire model neuron (for a more detailed discussion of the LIF Neuron, see the Appendix).

We also know that in addition to excitatory presynaptic neurons, we can have inhibitory presynaptic neurons as well. We can model these inhibitory neurons with another Poisson random variable:

\begin{align}
I = I_{exc} - I_{inh} \\
I_{exc} \sim \mathrm{Poisson}(\lambda_{exc}) \\
I_{inh} \sim \mathrm{Poisson}(\lambda_{inh})
\end{align}

where $\lambda_{exc}$ and $\lambda_{inh}$ are the average spike rates (per timestep) of the excitatory and inhibitory presynaptic neurons, respectively.

### Exercise 2: Compute $dV_m$ with inhibitory signals

For your second exercise, you will again write the code to compute the change in voltage $dV_m$, though now of the LIF model neuron described above. Like last time, the rest of the code needed to handle the neuron dynamics are provided for you, so you just need to fill in a definition for `dv` below.

```python
def lif_neuron_inh(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  """ Simulate a simplified leaky integrate-and-fire neuron with both excitatory
  and inhibitory inputs.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    beta (float): The membrane potential leakage factor
    exc_rate (int): The mean rate of the incoming excitatory spikes
    inh_rate (int): The mean rate of the incoming inhibitory spikes
  """

  # precompute Poisson samples for speed
  exc = stats.poisson(exc_rate).rvs(n_steps)
  inh = stats.poisson(inh_rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []

  ###############################################################################
  # Students: compute dv, then comment out or remove the next line
  raise NotImplementedError("Excercise: compute the change in membrane potential")
  ################################################################################

  for i in range(1, n_steps):

    dv = ...

    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

# Uncomment these lines do make the plot once you've completed the function
#v, spike_times = lif_neuron_inh()
#plot_neuron_stats(v, spike_times)
```

```python
# to_remove solution
def lif_neuron_inh(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  """ Simulate a simplified leaky integrate-and-fire neuron with both excitatory
  and inhibitory inputs.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    beta (float): The membrane potential leakage factor
    exc_rate (int): The mean rate of the incoming excitatory spikes
    inh_rate (int): The mean rate of the incoming inhibitory spikes
  """
  # precompute Poisson samples for speed
  exc = stats.poisson(exc_rate).rvs(n_steps)
  inh = stats.poisson(inh_rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []
  for i in range(1, n_steps):

    dv = -beta * v[i-1] + alpha * (exc[i] - inh[i])

    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

v, spike_times = lif_neuron_inh()

with plt.xkcd():
  plot_neuron_stats(v, spike_times)
```

## Interactive Demo: LIF + inhibition neuron

```python
#@title
#@markdown **Run the cell** to enable the sliders.
def _lif_neuron_inh(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  """ Simulate a simplified leaky integrate-and-fire neuron with both excitatory
  and inhibitory inputs.

  Args:
    n_steps (int): The number of time steps to simulate the neuron's activity.
    alpha (float): The input scaling factor
    beta (float): The membrane potential leakage factor
    exc_rate (int): The mean rate of the incoming excitatory spikes
    inh_rate (int): The mean rate of the incoming inhibitory spikes
  """
  # precompute Poisson samples for speed
  exc = stats.poisson(exc_rate).rvs(n_steps)
  inh = stats.poisson(inh_rate).rvs(n_steps)

  v = np.zeros(n_steps)
  spike_times = []
  for i in range(1, n_steps):
    dv = -beta * v[i-1] + alpha * (exc[i] - inh[i])
    v[i] = v[i-1] + dv
    if v[i] > 1:
      spike_times.append(i)
      v[i] = 0

  return v, spike_times

@widgets.interact(n_steps=widgets.FloatLogSlider(1000.0, min=2.5, max=4),
                  alpha=widgets.FloatLogSlider(0.5, min=-1, max=1),
                  beta=widgets.FloatLogSlider(0.1, min=-1, max=0),
                  exc_rate=widgets.IntSlider(12, min=10, max=20),
                  inh_rate=widgets.IntSlider(12, min=10, max=20))
def plot_lif_neuron(n_steps=1000, alpha=0.5, beta=0.1, exc_rate=10, inh_rate=10):
  v, spike_times = _lif_neuron_inh(int(n_steps), alpha, beta, exc_rate, inh_rate)
  plot_neuron_stats(v, spike_times)
```

```python
#@title Video 3: LIF + inhibition
from IPython.display import IFrame
class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
        self.id=id
        src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
        super(BiliVideo, self).__init__(src, width, height, **kwargs)

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

    Video available at https://www.bilibili.com/video/BV1nV41167mS

#Summary

In this tutorial we gained some intuition for the mechanisms that produce the observed behavior in our real neural data. First, we built a simple neuron model with excitatory input and saw that it's behavior, measured using the ISI distribution, did not match our real neurons. We then improved our model by adding leakiness and inhibitory input. The behavior of this balanced model was much closer to the real neural data.

# Bonus

### Why do neurons spike?

A neuron stores energy in an electric field across its cell membrane, by controlling the distribution of charges (ions) on either side of the membrane. This energy is rapidly discharged to generate a spike when the field potential (or membrane potential) crosses a threshold. The membrane potential may be driven toward or away from this threshold, depending on inputs from other neurons: excitatory or inhibitory, respectively. The membrane potential tends to revert to a resting potential, for example due to the leakage of ions across the membrane, so that reaching the spiking threshold depends not only on the amount of input ever received following the last spike, but also the timing of the inputs.

The storage of energy by maintaining a field potential across an insulating membrane can be modeled by a capacitor. The leakage of charge across the membrane can be modeled by a resistor. This is the basis for the leaky integrate-and-fire neuron model.

### The LIF Model Neuron

The full equation for the LIF neuron is

\begin{align}
C_{m}\frac{dV_m}{dt} = -(V_m - V_{rest})/R_{m} + I
\end{align}

where $C_m$ is the membrane capacitance, $R_M$ is the membrane resistance, $V_{rest}$ is the resting potential, and $I$ is some input current (from other neurons, an electrode, ...).

In our above examples we set many of these parameters to convenient values ($C_m = R_m = dt = 1$, $V_{rest} = 0$) to focus more on the general behavior of the model. However, these too can be manipulated to achieve different dynamics, or to ensure the dimensions of the problem are preserved between simulation units and experimental units (e.g. with $V_m$ given in millivolts, $R_m$ in megaohms, $t$ in milliseconds).


# 1d3v electron hybrid code for R/L-waves with stationary ions and homogeneoues background field

## 1. The model
The electron hybrid model for cold electrons with linearized fluid equations and hot kinetic electrons (Vlasov equation) reads

\begin{align}
&\frac{\partial\tilde{\mathbf{j}}_\mathrm{c}}{\partial t}=\epsilon_0\Omega_\mathrm{pe}^2\tilde{\mathbf{E}}+\tilde{\mathbf{j}}_\mathrm{c}\times\mathbf{\Omega}_\mathrm{ce}(\mathbf{x}),\\
&\frac{\partial f_\mathrm{h}}{\partial t}+\mathbf{v}\cdot\nabla f_\mathrm{h}+\frac{q_\mathrm{e}}{m_\mathrm{e}}(\mathbf{E}+\mathbf{v}\times\mathbf{B})\cdot\nabla_\mathbf{v}f_\mathrm{h}=0,\\
&\frac{\partial \tilde{\mathbf{B}}}{\partial t}=-\nabla\times\tilde{\mathbf{E}},\\
&\frac{1}{c^2}\frac{\partial \tilde{\mathbf{E}}}{\partial t}=\nabla\times\tilde{\mathbf{B}}-\mu_0\tilde{\mathbf{j}}_\mathrm{c}-\mu_0q_\mathrm{e}\int\mathbf{v}\tilde{f}_\mathrm{h}\,\mathrm{d}^3\mathbf{v},
\end{align}

where $\mathbf{\Omega}_\mathrm{ce}(\mathbf{x})=q_\mathrm{e}\mathbf{B}_0(\mathbf{x})/m_\mathrm{e}$ is the oriented electron cyclotron frequency corresponding to the background field $\mathbf{B}_0=\mathbf{B}_0(\mathbf{x})$ and $\Omega_{\mathrm{pe}}^2=n_{\mathrm{c}0}e^2/\epsilon_0m_\mathrm{e}$ the plasma frequency of the cold electrons. Here, only wave propagation parallel to the background magnetic field $\mathbf{B}_0=\mathbf{B}_0(\mathbf{x})$ is considered, i.e. $\textbf{k}=k\textbf{e}_z$. Therefore the nabla operator is simply $\nabla=\textbf{e}_z\partial_z$. The background magnetic field simply is $B(\mathbf{x})=B_0\mathbf{e}_z$.

We use periodic boundary conditions for all quantities.

## 2. The code

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

import bsplines as bsp

import time

import utilitis_opt as utils_opt
import utilitis_pic

#====================================================================================
#  calling epyccel
#====================================================================================
from pyccel.epyccel import epyccel
utils_pic_fast = epyccel(utilitis_pic)
print('pyccelization of pic functions done!')
#====================================================================================

#===== saving data? (save = 1: yes, save = 0: no). If yes, name directory ===========
save = 0
title = 'test.txt'
#====================================================================================

#===== save only every saving_step-th time step =====================================
saving_step = 1
#====================================================================================

#===== physical parameters ==========================================================
eps0 = 1.0                         # vacuum permittivity
mu0 = 1.0                          # vacuum permeability
c = 1.0                            # speed of light
qe = -1.0                          # electron charge
me = 1.0                           # electron mass
B0z = 1.0                          # minimum of background magnetic field in z-direction
wce = qe*B0z/me                    # electron cyclotron frequency
wpe = 2*np.abs(wce)                # cold electron plasma frequency
nuh = 0.06                         # ratio of cold/hot electron densities (nh/nc)
nh = nuh*wpe**2                    # hot electron density
wpar = 0.2*c                       # parallel thermal velocity of energetic particles
wperp = 0.53*c                     # perpendicular thermal velocity of energetic particles
#====================================================================================

#===== initial conditions ===========================================================
k = 2.                             # wavenumber of initial wave field perturbations
amp = 1e-4                         # amplitude of initial wave field perturbations
eps = 0.                           # amplitude of spatial pertubation of initial distribution function

Ex0 = lambda z : 0*z               # initial Ex
Ey0 = lambda z : 0*z               # initial Ey
Bx0 = lambda z : amp*np.sin(k*z)   # initial Bx
By0 = lambda z : 0*z               # initial By
jx0 = lambda z : 0*z               # initial jcx
jy0 = lambda z : 0*z               # initial jcy
#====================================================================================

#===== numerical parameters =========================================================
Lz = 2*np.pi/k                     # length of z-domain
Nel = 32                           # number of elements z-direction
T = 200.                           # simulation time
dt = 0.05                          # time step
p = 3                              # degree of B-spline basis functions
Np = np.int(1e5)                   # number of markers
control = 1                        # control variate for noise reduction? (1: yes, 0: no)
time_integr = 1                    # do time integration? (1 : yes, 0: no)
#====================================================================================

#===== create parameter list ========================================================
pa = np.zeros(1*Nel + 5)

pa[0]  = eps0
pa[1]  = mu0
pa[2]  = c
pa[3]  = qe
pa[4]  = me
pa[5]  = B0z
pa[6]  = wce
pa[7]  = wpe
pa[8]  = nuh
pa[9]  = nh
pa[10] = wpar
pa[11] = wperp
pa[12] = k
pa[13] = amp
pa[14] = eps
pa[15] = Lz
pa[16] = Nel
pa[17] = T
pa[18] = dt
pa[19] = p
pa[20] = Np
pa[21] = control
pa[22] = saving_step
#====================================================================================

#===== discretization of spatial domain =============================================
dz   = Lz/Nel                                # element size
el_b = np.linspace(0., Lz, Nel + 1)          # element boundaries

Nbase   = Nel + p                            # total number of basis functions
Nbase_0 = Nel                                # number of degrees of freedom
#====================================================================================

#===== some diagnostic values =======================================================
Eh_eq = Lz*nh*me/2*(wpar**2 + 2*wperp**2)    # equilibrium energetic electron energy

en_E = np.array([])                          # electric field energy
en_B = np.array([])                          # magnetic field energy
en_C = np.array([])                          # cold plasma energy
en_H = np.array([])                          # energetic electron energy
#====================================================================================

#===== system matrices for fluid electrons and electromagnetic fields ===============
A10 = np.array([0, 0, 0, +c**2, 0, 0])
A11 = np.array([0, 0, -c**2, 0, 0, 0])
A12 = np.array([0, -1, 0, 0, 0, 0])
A13 = np.array([+1, 0, 0, 0, 0, 0])
A14 = np.array([0, 0, 0, 0, 0, 0])
A15 = np.array([0, 0, 0, 0, 0, 0])
A1 = np.array([A10, A11, A12, A13, A14, A15])

A20 = np.array([0, 0, 0, 0, mu0*c**2, 0])
A21 = np.array([0, 0, 0, 0, 0, mu0*c**2])
A22 = np.array([0, 0, 0, 0, 0, 0])
A23 = np.array([0, 0, 0, 0, 0, 0])
A24 = np.array([-eps0*wpe**2, 0, 0, 0, 0, -wce])
A25 = np.array([0, -eps0*wpe**2, 0, 0, +wce, 0])
A2 = np.array([A20, A21, A22, A23, A24, A25])

s = int(np.sqrt(A1.size))
#====================================================================================

#===== initial energetic electron distribution function =============================
fh0 = lambda z, vx, vy, vz : (1 + eps*np.cos(k*z))*nh/((2*np.pi)**(3/2)*wpar*wperp**2)*np.exp(-vz**2/(2*wpar**2) - (vx**2 + vy**2)/(2*wperp**2))
#====================================================================================

#===== Maxwellian for control variate ===============================================
maxwell = lambda vx, vy, vz : nh/((2*np.pi)**(3/2)*wpar*wperp**2)*np.exp(-vz**2/(2*wpar**2) - (vx**2 + vy**2)/(2*wperp**2))
#====================================================================================

#===== sampling distribution for initial markers ====================================
g_sampling = lambda vx, vy, vz : 1/((2*np.pi)**(3/2)*wpar*wperp**2)*np.exp(-vz**2/(2*wpar**2) - (vx**2 + vy**2)/(2*wperp**2))*1/Lz
#====================================================================================

#===== spline knot vector, mass and advection matrix ================================
Tz = bsp.make_knots(el_b, p, True)
M, C = utils_opt.matrixAssembly_V0(p, Nbase, Tz, True)

print('matrix assembly done!')
#====================================================================================

#===== initial coefficients with L2-projection ======================================
u0 = np.zeros((Nbase, s))

u0[:, 0] = utils_opt.L2_prod_V0(Ex0, p, Nbase, Tz)
u0[:, 1] = utils_opt.L2_prod_V0(Ey0, p, Nbase, Tz)
u0[:, 2] = utils_opt.L2_prod_V0(Bx0, p, Nbase, Tz)
u0[:, 3] = utils_opt.L2_prod_V0(By0, p, Nbase, Tz)
u0[:, 4] = utils_opt.L2_prod_V0(jx0, p, Nbase, Tz)
u0[:, 5] = utils_opt.L2_prod_V0(jy0, p, Nbase, Tz)

u0[:p] += u0[-p:]
u0 = u0[:u0.shape[0] - p]

u0 = np.linalg.solve(M, u0)
uj = np.reshape(u0, s*Nbase_0)

print('L2-projection of initial fields done!')
#====================================================================================

#===== construct block matrices =====================================================
M_block = np.kron(M, np.identity(6))
C_tilde = np.kron(C, A1)
M_tilde = np.kron(M, A2)

print('block matrices done!')
#====================================================================================

#===== create particles (z,vx,vy,vz,wk) and sample according to sampling distribution
particles = np.zeros((Np, 5), order='F')
particles[:, 0] = np.random.rand(Np)*Lz
particles[:, 1] = np.random.randn(Np)*wperp
particles[:, 2] = np.random.randn(Np)*wperp
particles[:, 3] = np.random.randn(Np)*wpar

jh = np.zeros(2*Nbase_0)
Fh = np.zeros(s*Nbase_0)

z_old = np.empty(Np)
#====================================================================================

#===== parameters for control variate ===============================================
g0 = g_sampling(particles[:, 1], particles[:, 2], particles[:, 3])
w0 = fh0(particles[:, 0], particles[:, 1], particles[:, 2], particles[:, 3])/g_sampling(particles[:, 1], particles[:, 2], particles[:, 3])
#====================================================================================

#===== initialize velocities by pushing back by -dt/2, compute weights and energy of hot particles
timea = time.time()

z_old[:] = particles[:, 0]

utils_pic_fast.borisPush_bc_1(particles, -dt/2, qe, me, Lz, Tz, p, Nbase_0, uj[0::s], uj[1::s], uj[2::s], uj[3::s], B0z)

particles[:, 0] = z_old
particles[:, 4] = w0 - control*maxwell(particles[:, 1], particles[:, 2], particles[:, 3])/g0

timeb = time.time()
print('time for particle push: ' + str(timeb - timea))
#====================================================================================

#===== test timing for hot current computation ======================================
timea = time.time()

utils_pic_fast.hotCurrent_bc_1(particles[:, 1:3], particles[:, 0], particles[:, 4], Tz, p, Nbase_0, qe, jh)

timeb = time.time()
print('time for hot current computation: ' + str(timeb - timea))
#====================================================================================

#===== compute matrices for field update ============================================
LHS = sc.sparse.csc_matrix(M_block + 1/2*dt*C_tilde + 1/2*dt*M_tilde)
RHS = sc.sparse.csc_matrix(M_block - 1/2*dt*C_tilde - 1/2*dt*M_tilde)

LU = sc.sparse.linalg.splu(LHS)

print('LU factorization done!')
#====================================================================================

#===== test timing for linear solver ================================================
timea = time.time()

LU.solve(RHS.dot(uj) + dt*Fh)

timeb = time.time()
print('time for solving linear system: ' + str(timeb - timea))
#====================================================================================

#===== time integration by a time step dt ===========================================
def update():
    # ... save old positions
    z_old = particles[:, 0]
    # ...

    # ... update particle velocities from n-1/2 to n+1/2 with fields at time n and positions from n to n+1 with velocities at n+1/2
    utils_pic_fast.borisPush_bc_1(particles, dt, qe, me, Lz, Tz, p, Nbase_0, uj[0::s], uj[1::s], uj[2::s], uj[3::s], B0z)
    # ...

    # ... update weights with control variate
    particles[:, 4] = w0 - control*maxwell(particles[:, 1], particles[:, 2], particles[:, 3])/g0
    # ...

    # ... compute hot electron current densities
    utils_pic_fast.hotCurrent_bc_1(particles[:, 1:3], 1/2*(z_old + particles[:, 0]), particles[:, 4], Tz, p, Nbase_0, qe, jh)
    # ...

    # ... assemble right-hand side of weak formulation
    Fh[0::s] = -c**2*mu0*jh[0::2]
    Fh[1::s] = -c**2*mu0*jh[1::2]
    # ...

    # ... time integration of E, B, jc from n to n+1 with Crank-Nicolson method (use hot current density at n+1/2)
    uj[:] = LU.solve(RHS.dot(uj) + dt*Fh)
    # ...
#====================================================================================

#===== create data file and save parameters (first row), initial fields and energies (second row)
if save == 1:
    file = open(title, 'ab')
    np.savetxt(file, np.reshape(pa, (1, 1*Nbase_0 + 5)), fmt = '%1.10e')

en_E = np.append(en_E, eps0/2*(np.dot(uj[0::s], np.dot(M, uj[0::s])) + np.dot(uj[1::s], np.dot(M, uj[1::s]))))
en_B = np.append(en_B, eps0/(2*mu0)*(np.dot(uj[2::s], np.dot(M, uj[2::s])) + np.dot(uj[3::s], np.dot(M, uj[3::s]))))
en_C = np.append(en_C, 1/(2*eps0*wpe**2)*(np.dot(uj[4::s], np.dot(M, uj[4::s])) + np.dot(uj[5::s], np.dot(M, uj[5::s]))))
en_H = np.append(en_H, me/(2*Np)*np.dot(particles[:, 4], particles[:, 1]**2 + particles[:, 2]**2 + particles[:, 3]**2) + control*Eh_eq)

bx_save = np.append(np.copy(uj[2::s]), uj[2])

if save == 1:
    data = np.append(uj[2::s], np.array([en_E[-1], en_B[-1], en_C[-1], en_H[-1], 0.]))
    np.savetxt(file, np.reshape(data, (1, 1*Nbase_0 + 5)), fmt = '%1.10e')
#====================================================================================

#===== time integration =============================================================
if time_integr == 1:

    print('start time integration! (number of time steps : ' + str(int(T/dt)) + ')')
    time_step = 0

    while True:

        try:
            if time_step*dt >= T:
                if save == 1:
                    file.close()
                break

            if time_step%50 == 0:
                print('time steps finished: ' + str(time_step))

            update()

            if time_step%saving_step == 0:

                # ... add data to file
                en_E = np.append(en_E, eps0/2*(np.dot(uj[0::s], np.dot(M, uj[0::s])) + np.dot(uj[1::s], np.dot(M, uj[1::s]))))
                en_B = np.append(en_B, eps0/(2*mu0)*(np.dot(uj[2::s], np.dot(M, uj[2::s])) + np.dot(uj[3::s], np.dot(M, uj[3::s]))))
                en_C = np.append(en_C, 1/(2*eps0*wpe**2)*(np.dot(uj[4::s], np.dot(M, uj[4::s])) + np.dot(uj[5::s], np.dot(M, uj[5::s]))))
                en_H = np.append(en_H, me/(2*Np)*np.dot(particles[:, 4], particles[:, 1]**2 + particles[:, 2]**2 + particles[:, 3]**2) + control*Eh_eq)

                bx_save = np.vstack((bx_save, np.append(np.copy(uj[2::s]), uj[2])))

                if save == 1:
                    data = np.append(uj[2::s], np.array([en_E[-1], en_B[-1], en_C[-1], en_H[-1], (time_step + 1)*dt]))
                    np.savetxt(file, np.reshape(data, (1, 1*Nbase_0 + 5)), fmt = '%1.10e')
                # ...

            time_step += 1
        except KeyboardInterrupt:
            print('Pausing...  (Hit ENTER to continue, type quit to exit.)')

            if save == 1:
                file.close()

            try:
                response = input()
                if response == 'quit':
                    break
                print('Resuming...')

                if save == 1:
                    file = open(title, 'ab')

            except KeyboardInterrupt:
                print('Resuming...')

                if save == 1:
                    file = open(title, 'ab')

                continue

    if save == 1:
        file.close()
#====================================================================================
```

    pyccelization of pic functions done!
    matrix assembly done!
    L2-projection of initial fields done!
    block matrices done!
    time for particle push: 0.05168342590332031
    time for hot current computation: 0.014675140380859375
    LU factorization done!
    time for solving linear system: 0.0004973411560058594
    start time integration! (number of time steps : 4000)
    time steps finished: 0
    time steps finished: 50
    time steps finished: 100
    time steps finished: 150
    time steps finished: 200
    time steps finished: 250
    time steps finished: 300
    time steps finished: 350
    time steps finished: 400
    time steps finished: 450
    time steps finished: 500
    time steps finished: 550
    time steps finished: 600
    time steps finished: 650
    time steps finished: 700
    time steps finished: 750
    time steps finished: 800
    time steps finished: 850
    time steps finished: 900
    time steps finished: 950
    time steps finished: 1000
    time steps finished: 1050
    time steps finished: 1100
    time steps finished: 1150
    time steps finished: 1200
    time steps finished: 1250
    time steps finished: 1300
    time steps finished: 1350
    time steps finished: 1400
    time steps finished: 1450
    time steps finished: 1500
    time steps finished: 1550
    time steps finished: 1600
    time steps finished: 1650
    time steps finished: 1700
    time steps finished: 1750
    time steps finished: 1800
    time steps finished: 1850
    time steps finished: 1900
    time steps finished: 1950
    time steps finished: 2000
    time steps finished: 2050
    time steps finished: 2100
    time steps finished: 2150
    time steps finished: 2200
    time steps finished: 2250
    time steps finished: 2300
    time steps finished: 2350
    time steps finished: 2400
    time steps finished: 2450
    time steps finished: 2500
    time steps finished: 2550
    time steps finished: 2600
    time steps finished: 2650
    time steps finished: 2700
    time steps finished: 2750
    time steps finished: 2800
    time steps finished: 2850
    time steps finished: 2900
    time steps finished: 2950
    time steps finished: 3000
    time steps finished: 3050
    time steps finished: 3100
    time steps finished: 3150
    time steps finished: 3200
    time steps finished: 3250
    time steps finished: 3300
    time steps finished: 3350
    time steps finished: 3400
    time steps finished: 3450
    time steps finished: 3500
    time steps finished: 3550
    time steps finished: 3600
    time steps finished: 3650
    time steps finished: 3700
    time steps finished: 3750
    time steps finished: 3800
    time steps finished: 3850
    time steps finished: 3900
    time steps finished: 3950

```python
tn = np.linspace(0., T, len(en_B))
plt.semilogy(tn, en_B)

omega = utils_opt.solveDispersionHybrid(k, +1, c, wce, wpe, wpar, wperp, nuh, 0.5 + 0j, 1e-8)[0]
omega_r = np.real(omega)
gamma = np.imag(omega)

t2 = np.linspace(0, 150, 100)
plt.semilogy(t2, 2e-9*np.exp(gamma*2*t2), 'k--', label = 'expected growth')
```

```python

```