# Neuromatch Academy: Week 3, Day 1, Tutorial 1
# Real Neurons: The Leaky Integrate-and-Fire (LIF) Neuron Model
__Content creators:__ Qinglong Gu, Songtin Li, John Murray, Richard Naud, Arvind Kumar

__Content reviewers:__  Maryam Vaziri-Pashkam, Ella Batty, Lorenzo Fontolan, Richard Gao, Matthew Krause, Spiros Chavlis, Michael Waskom

---
# Tutorial Objectives
This is Tutorial 1 of a series on implementing realistic neuron models. In this tutorial, we will build up a leaky integrate-and-fire (LIF) neuron model and study its dynamics in response to various types of inputs. In particular, we are going to write a few lines of code to:

- simulate the LIF neuron model

- drive the LIF neuron with external inputs, such as direct currents, Gaussian white noise, and Poisson spike trains, etc.

- study how different inputs affect the  LIF neuron's output (firing rate and spike time irregularity)

Here, we will especially emphasize identifying conditions (input statistics) under which a neuron can spike at low firing rates and in an irregular manner. The reason for focusing on this is that in most cases, neocortical neurons spike in an irregular manner.

---
# Setup

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

```python
# @title Figure Settings
import ipywidgets as widgets  # interactive display
%config InlineBackend.figure_format = 'retina'
# use NMA plot style
plt.style.use("https://raw.githubusercontent.com/NeuromatchAcademy/course-content/master/nma.mplstyle")
my_layout = widgets.Layout()
```

```python
# @title Helper functions

def plot_volt_trace(pars, v, sp):
  """
  Plot trajetory of membrane potential for a single neuron

  Expects:
  pars   : parameter dictionary
  v      : volt trajetory
  sp     : spike train

  Returns:
  figure of the membrane potential trajetory for a single neuron
  """

  V_th = pars['V_th']
  dt, range_t = pars['dt'], pars['range_t']
  if sp.size:
    sp_num = (sp / dt).astype(int) - 1
    v[sp_num] += 20  # draw nicer spikes

  plt.plot(pars['range_t'], v, 'b')
  plt.axhline(V_th, 0, 1, color='k', ls='--')
  plt.xlabel('Time (ms)')
  plt.ylabel('V (mV)')
  plt.legend(['Membrane\npotential', r'Threshold V$_{\mathrm{th}}$'],
             loc=[1.05, 0.75])
  plt.ylim([-80, -40])

def plot_GWN(pars, I_GWN):
  """
  Args:
    pars  : parameter dictionary
    I_GWN : Gaussian white noise input

  Returns:
    figure of the gaussian white noise input
  """

  plt.figure(figsize=(12, 4))
  plt.subplot(121)
  plt.plot(pars['range_t'][::3], I_GWN[::3], 'b')
  plt.xlabel('Time (ms)')
  plt.ylabel(r'$I_{GWN}$ (pA)')
  plt.subplot(122)
  plot_volt_trace(pars, v, sp)
  plt.tight_layout()

def my_hists(isi1, isi2, cv1, cv2, sigma1, sigma2):
  """
  Args:
    isi1 : vector with inter-spike intervals
    isi2 : vector with inter-spike intervals
    cv1  : coefficient of variation for isi1
    cv2  : coefficient of variation for isi2

  Returns:
    figure with two histograms, isi1, isi2

  """
  plt.figure(figsize=(11, 4))
  my_bins = np.linspace(10, 30, 20)
  plt.subplot(121)
  plt.hist(isi1, bins=my_bins, color='b', alpha=0.5)
  plt.xlabel('ISI (ms)')
  plt.ylabel('count')
  plt.title(r'$\sigma_{GWN}=$%.1f, CV$_{\mathrm{isi}}$=%.3f' % (sigma1, cv1))

  plt.subplot(122)
  plt.hist(isi2, bins=my_bins, color='b', alpha=0.5)
  plt.xlabel('ISI (ms)')
  plt.ylabel('count')
  plt.title(r'$\sigma_{GWN}=$%.1f, CV$_{\mathrm{isi}}$=%.3f' % (sigma2, cv2))
  plt.tight_layout()
  plt.show()

# the function plot the raster of the Poisson spike train
def my_raster_Poisson(range_t, spike_train, n):
  """
  Generates poisson trains

  Args:
    range_t     : time sequence
    spike_train : binary spike trains, with shape (N, Lt)
    n           : number of Poisson trains plot

  Returns:
    Raster plot of the spike train
  """

  # find the number of all the spike trains
  N = spike_train.shape[0]

  # n should smaller than N:
  if n > N:
    print('The number n exceeds the size of spike trains')
    print('The number n is set to be the size of spike trains')
    n = N

  # plot rater
  i = 0
  while i < n:
    if spike_train[i, :].sum() > 0.:
      t_sp = range_t[spike_train[i, :] > 0.5]  # spike times
      plt.plot(t_sp, i * np.ones(len(t_sp)), 'k|', ms=10, markeredgewidth=2)
    i += 1
  plt.xlim([range_t[0], range_t[-1]])
  plt.ylim([-0.5, n + 0.5])
  plt.xlabel('Time (ms)', fontsize=12)
  plt.ylabel('Neuron ID', fontsize=12)
```

---
# Section 1: The Leaky Integrate-and-Fire (LIF) model

```python
#@title Video 1: Reduced Neuron Models
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='rSExvwCVRYg', width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=rSExvwCVRYg

## Implementation of an LIF neuron model

Now, it's your turn to implement one of the simplest mathematical model of a neuron: the leaky integrate-and-fire (LIF) model. The basic idea of LIF neuron was proposed in 1907 by Louis Édouard Lapicque, long before we understood the electrophysiology of a neuron (see a translation of [Lapicque's paper](https://pubmed.ncbi.nlm.nih.gov/17968583/) ). More details of the model can be found in the book [**Theoretical neuroscience**](http://www.gatsby.ucl.ac.uk/~dayan/book/) by Peter Dayan and Laurence F. Abbott.

The subthreshold membrane potential dynamics of a LIF neuron is described by

\begin{eqnarray}
C_m\frac{dV}{dt} = -g_L(V-E_L) + I,\quad (1)
\end{eqnarray}

where $C_m$ is the membrane capacitance, $V$ is the membrane potential, $g_L$ is the leak conductance ($g_L = 1/R$, the inverse of the leak resistance $R$ mentioned in previous tutorials), $E_L$ is the resting potential, and $I$ is the external input current.

Dividing both sides of the above equation by $g_L$ gives

\begin{align}
\tau_m\frac{dV}{dt} = -(V-E_L) + \frac{I}{g_L}\,,\quad (2)
\end{align}

where the $\tau_m$ is membrane time constant and is defined as $\tau_m=C_m/g_L$.

You might wonder why dividing capacitance by conductance gives units of time! Find out yourself why this is true.

Below, we will use Eqn.(2) to simulate LIF neuron dynamics.

If $I$ is sufficiently strong such that $V$ reaches a certain threshold value $V_{\rm th}$, $V$ is reset to a reset potential $V_{\rm reset}< V_{\rm th}$, and voltage is clamped to $V_{\rm reset}$ for $\tau_{\rm ref}$ ms, mimicking the refractoriness of the neuron during an action potential:

\begin{eqnarray}
\mathrm{if}\quad V(t_{\text{sp}})\geq V_{\rm th}&:& V(t)=V_{\rm reset} \text{  for } t\in(t_{\text{sp}}, t_{\text{sp}} + \tau_{\text{ref}}]
\end{eqnarray}
where $t_{\rm sp}$ is the spike time when $V(t)$ just exceeded $V_{\rm th}$.

(__Note__: in the lecture slides, $\theta$ corresponds to the threshold voltage $V_{th}$, and $\Delta$ corresponds to the refractory time $\tau_{\rm ref}$.)

Thus, the LIF model captures the facts that a neuron:
- performs spatial and temporal integration of synaptic inputs
- generates a spike when the voltage reaches a certain threshold
- goes refractory during the action potential
- has a leaky membrane

The LIF model assumes that the spatial and temporal integration of inputs is linear. Also, membrane potential dynamics close to the spike threshold are much slower in LIF neurons than in real neurons.

## Exercise 1: Python code to simulate the LIF neuron

We now write Python code to calculate Eqn. (2) and simulate the LIF neuron dynamics. We will use the Euler method, which you saw in the linear systems case last week,  to numerically integrate Eq 2.

The cell below initializes a dictionary that stores parameters of the LIF neuron model and the simulation scheme. You can use `pars=default_pars(T=simulation_time, dt=time_step)` to get the parameters (you can try to print the dictionary `pars`). Note that, `simulation_time` and `time_step` have the unit `ms`. In addition, you can add the value to a new parameter by `pars['New_param'] = value`.

```python
# @title

# @markdown Execute this code to initialize the default parameters

def default_pars(**kwargs):
  pars = {}

  # typical neuron parameters#
  pars['V_th'] = -55.     # spike threshold [mV]
  pars['V_reset'] = -75.  # reset potential [mV]
  pars['tau_m'] = 10.     # membrane time constant [ms]
  pars['g_L'] = 10.       # leak conductance [nS]
  pars['V_init'] = -75.   # initial potential [mV]
  pars['E_L'] = -75.      # leak reversal potential [mV]
  pars['tref'] = 2.       # refractory time (ms)

  # simulation parameters #
  pars['T'] = 400.  # Total duration of simulation [ms]
  pars['dt'] = .1   # Simulation time step [ms]

  # external parameters if any #
  for k in kwargs:
    pars[k] = kwargs[k]

  pars['range_t'] = np.arange(0, pars['T'], pars['dt'])  # Vector of discretized time points [ms]

  return pars

pars = default_pars()
```

The cell below defines the function to simulate the LIF neuron when receiving external current inputs. You can use `v, sp = run_LIF(pars, Iinj)` to get the membrane potential (`v`) and spike train (`sp`) given the dictionary `pars` and input current `Iinj`.

```python
def run_LIF(pars, Iinj, stop=False):
  """
  Simulate the LIF dynamics with external input current

  Args:
    pars       : parameter dictionary
    Iinj       : input current [pA]. The injected current here can be a value
                 or an array
    stop       : boolean. If True, use a current pulse

  Returns:
    rec_v      : membrane potential
    rec_sp     : spike times
  """

  # Set parameters
  V_th, V_reset = pars['V_th'], pars['V_reset']
  tau_m, g_L = pars['tau_m'], pars['g_L']
  V_init, E_L = pars['V_init'], pars['E_L']
  dt, range_t = pars['dt'], pars['range_t']
  Lt = range_t.size
  tref = pars['tref']

  # Initialize voltage and current
  v = np.zeros(Lt)
  v[0] = V_init
  Iinj = Iinj * np.ones(Lt)

  if stop:  # set end of current to 0 if current pulse
    Iinj[:int(len(Iinj) / 2) - 1000] = 0
    Iinj[int(len(Iinj) / 2) + 1000:] = 0

  tr = 0.  # the count for refractory duration

  # Simulate the LIF dynamics
  rec_spikes = []  # record spike times
  for it in range(Lt - 1):
    if tr > 0:  # check for refractoriness
      v[it+1] = V_reset
      tr = tr - 1
    else:
      # forward euler
      dv = (-(v[it] - E_L) + Iinj[it] / g_L) * (dt / tau_m)
      v[it+1] = v[it] + dv

      if v[it+1] >= V_th:  # reset voltage and record spike event
        rec_spikes.append(it+1)
        v[it+1] = V_reset
        tr = tref / dt

  rec_spikes = np.array(rec_spikes) * dt

  return v, rec_spikes

pars = default_pars(T=500)
pars['tref'] = 10
# Uncomment below to test your function
v, sp = run_LIF(pars, Iinj=220, stop=True)
plot_volt_trace(pars, v, sp)
# plt.ylim([-80, -60])
plt.show()
```

---
# Section 2: Response of an LIF model to different types of input currents
In the following section, we will learn how to inject direct current and white noise to study the response of an LIF neuron.

```python
#@title Video 2: Response of the LIF neuron to different inputs
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='preNGdab7Kk', width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=preNGdab7Kk

## Section 2.1: Direct current (DC)

### LIF neuron driven by constant current
Investigate the voltage response of the LIF neuron when receiving a DC input of 300 pA by `run_LIF` function.

```python
pars = default_pars(T=100)  # get the parameters

# Run the model to obtain v and sp
v, sp = run_LIF(pars, Iinj=300)

plot_volt_trace(pars, v, sp)
plt.show()
```

In the plot above, you see the membrane potential of an LIF neuron. You may notice that the neuron generates a spike. But this is just a cosmetic spike only for illustration purposes. In an LIF neuron, we only need to keep track of times when the neuron hit the threshold so the postsynaptic neurons can be informed of the spike.

### Interactive Demo: Parameter exploration of DC input amplitude
Here's an interactive demo that shows how the LIF neuron behavior changes for DC input with different amplitudes.

How much DC is needed to reach the threshold (rheobase current)? How does the membrane time constant affect the frequency of the neuron?

```python
# @title
# @markdown Make sure you execute this cell to enable the widget!
my_layout.width = '450px'
@widgets.interact(
    I_dc=widgets.FloatSlider(50., min=0., max=300., step=10.,
                               layout=my_layout),
    tau_m=widgets.FloatSlider(10., min=2., max=20., step=2.,
                                layout=my_layout)
)

def diff_DC(I_dc=200., tau_m=10.):
  pars = default_pars(T=100.)
  pars['tau_m'] = tau_m
  v, sp = run_LIF(pars, Iinj=I_dc)
  plot_volt_trace(pars, v, sp)
  plt.show()
```

    interactive(children=(FloatSlider(value=50.0, description='I_dc', layout=Layout(width='450px'), max=300.0, ste…

```python
"""
1. As we increase the current, we observe that at 210 pA we cross the threshold.

2. As we increase the membrane time constant (slower membrane), the firing rate
is decreased because the membrane needs more time to reach the threshold after
the reset.
""";
```

## Section 2.2: Gaussian white noise (GWN) current

Given the noisy nature of neuronal activity _in vivo_, neurons usually receive complex, time-varying inputs.

To mimic this, we will now investigate the neuronal response when the LIF neuron receives Gaussian white noise $\xi(t)$ with mean

\begin{eqnarray}
E[\xi(t)]=\mu=0,
\end{eqnarray}

and autocovariance
\begin{eqnarray}
E[\xi(t)\xi(t+\tau)]=\sigma_\xi^2 \delta(\tau)
\end{eqnarray}

Note that the GWN has zero mean, that is, it describes only the fluctuations of the input received by a neuron. We can thus modify our definition of GWN to have a nonzero mean value $\mu$ that equals the DC input, since this is the average input into the cell. The cell below defines the modified gaussian white noise currents with nonzero mean $\mu$.

```python
#@title
#@markdown Execute this cell to get function to generate GWN

def my_GWN(pars, mu, sig, myseed=False):
  """
  Function that generates Gaussian white noise input

  Args:
    pars       : parameter dictionary
    mu         : noise baseline (mean)
    sig        : noise amplitute (standard deviation)
    myseed     : random seed. int or boolean
                 the same seed will give the same
                 random number sequence

  Returns:
    I          : Gaussian white noise input
  """

  # Retrieve simulation parameters
  dt, range_t = pars['dt'], pars['range_t']
  Lt = range_t.size

  # set random seed
  # you can fix the seed of the random number generator so that the results
  # are reliable however, when you want to generate multiple realization
  # make sure that you change the seed for each new realization.
  if myseed:
      np.random.seed(seed=myseed)
  else:
      np.random.seed()

  # generate GWN
  # we divide here by 1000 to convert units to sec.
  I_gwn = mu + sig * np.random.randn(Lt) / np.sqrt(dt / 1000.)

  return I_gwn
```

### Exercise 2: LIF neuron driven by GWN

You can generate a noisy input with `my_GWN(pars, mu, sig, myseed=False)`. Here, $\mu=250$ and $\sigma=5$. Note that fixing the value of the random seed (e.g., `myseed=2020`) will allow you to obtain the same result every time you run this.

```python
pars = default_pars(T=100.)
sig_gwn = 5.
mu_gwn = 250.

# Calculate the GWN current
I_GWN = my_GWN(pars, mu=mu_gwn, sig=sig_gwn, myseed=2020)

# Run the model and calculate the v and the sp
v, sp = run_LIF(pars, Iinj=I_GWN)

plot_GWN(pars, I_GWN)
plt.show()
```

### Interactive Demo: LIF neuron Explorer for noisy input

The mean of the GWN is the amplitude of DC. Indeed, when $\sigma = 0$, GWN is just a DC.

So the question arises how does $\sigma$ of the GWN affect the spiking behavior of the neuron. For instance we may want to know
- how does the minimum input (i.e. $\mu$) needed to make a neuron spike change with increase in $\sigma$
- how does the spike regularity change with increase in $\sigma$

To get an intuition about these questions you can use the following interactive demo that shows how the LIF neuron behavior changes for noisy input with different amplitudes (the mean $\mu$) and fluctuation sizes ($\sigma$).

```python
# @title

# @markdown Make sure you execute this cell to enable the widget!

my_layout.width = '450px'
@widgets.interact(
    mu_gwn=widgets.FloatSlider(200., min=100., max=300., step=5.,
                               layout=my_layout),
    sig_gwn=widgets.FloatSlider(2.5, min=0., max=5., step=.5,
                                layout=my_layout)
)

def diff_GWN_to_LIF(mu_gwn, sig_gwn):
  pars = default_pars(T=100.)
  I_GWN = my_GWN(pars, mu=mu_gwn, sig=sig_gwn)
  v, sp = run_LIF(pars, Iinj=I_GWN)
  plt.figure(figsize=(12, 4))
  plt.subplot(121)
  plt.plot(pars['range_t'][::3], I_GWN[::3], 'b')
  plt.xlabel('Time (ms)')
  plt.ylabel(r'$I_{GWN}$ (pA)')
  plt.subplot(122)
  plot_volt_trace(pars, v, sp)
  plt.tight_layout()
  plt.show()
```

    interactive(children=(FloatSlider(value=200.0, description='mu_gwn', layout=Layout(width='450px'), max=300.0, …

```python
"""
If we have bigger current fluctuations (increased sigma), the minimum input needed
to make a neuron spike is smaller as the fluctuations can help push the voltage above
threshold.

The standard deviation (or size of current fluctuations) dictates the level of
irregularity of the spikes; the higher the sigma the more irregular the observed
spikes.

""";
```

## Think!
- As we increase the input average ($\mu$) or the input fluctuation ($\sigma$), the spike count changes. How much can we increase the spike count, and what might be the relationship between GWN mean/std or DC value and spike count?

- We have seen above that when we inject DC, the neuron spikes in a regular manner (clock like), and this regularity is reduced when GWN is injected. The question is, how irregular can we make the neurons spiking by changing the parameters of the GWN?

---
# Section 3: Firing rate and spike time irregularity
When we plot the output firing rate as a function of GWN mean or DC value, it is called the input-output transfer function of the neuron (so simply F-I curve).

Spike regularity can be quantified as the **coefficient of variance (CV) of the inter-spike-interval (ISI)**:
\begin{align}
\text{CV}_{\text{ISI}} = \frac{std(\text{ISI})}{mean(\text{ISI})}
\end{align}

A Poisson train is an example of high irregularity, in which $\textbf{CV}_{\textbf{ISI}} \textbf{= 1}$. And for a clocklike (regular) process we have $\textbf{CV}_{\textbf{ISI}} \textbf{= 0}$ because of **std(ISI)=0**.

## Interactive Demo: F-I Explorer for different `sig_gwn`

How does the F-I curve of the LIF neuron change as we increase the $\sigma$ of the GWN? We can already expect that the F-I curve will be stochastic and the results will vary from one trial to another. But will there be any other change compared to the F-I curved measured using DC?

Here's an interactive demo that shows how the F-I curve of a LIF neuron changes for different levels of fluctuation $\sigma$.

```python
# @title

# @markdown Make sure you execute this cell to enable the widget!

my_layout.width = '450px'
@widgets.interact(
    sig_gwn=widgets.FloatSlider(3.0, min=0., max=6., step=0.5,
                                layout=my_layout)
)

def diff_std_affect_fI(sig_gwn):
  pars = default_pars(T=1000.)
  I_mean = np.arange(100., 400., 10.)
  spk_count = np.zeros(len(I_mean))
  spk_count_dc = np.zeros(len(I_mean))

  for idx in range(len(I_mean)):
      I_GWN = my_GWN(pars, mu=I_mean[idx], sig=sig_gwn, myseed=2020)
      v, rec_spikes = run_LIF(pars, Iinj=I_GWN)
      v_dc, rec_sp_dc = run_LIF(pars, Iinj=I_mean[idx])
      spk_count[idx] = len(rec_spikes)
      spk_count_dc[idx] = len(rec_sp_dc)

  # Plot the F-I curve i.e. Output firing rate as a function of input mean.
  plt.figure()
  plt.plot(I_mean, spk_count, 'k',
           label=r'$\sigma_{\mathrm{GWN}}=%.2f$' % sig_gwn)
  plt.plot(I_mean, spk_count_dc, 'k--', alpha=0.5, lw=4, dashes=(2, 2),
           label='DC input')
  plt.ylabel('Spike count')
  plt.xlabel('Average injected current (pA)')
  plt.legend(loc='best')
  plt.show()
```

    interactive(children=(FloatSlider(value=3.0, description='sig_gwn', layout=Layout(width='450px'), max=6.0, ste…

```python
"""
Discussion: If we use a DC input, the F-I curve is deterministic, and we can
found its shape by solving the membrane equation of the neuron. If we have GWN,
as we increase the sigma, the F-I curve has a more linear shape, and the neuron
reaches its threshold using less average injected current.
"""
```

    '\nDiscussion: If we use a DC input, the F-I curve is deterministic, and we can \nfound its shape by solving the membrane equation of the neuron. If we have GWN, \nas we increase the sigma, the F-I curve has a more linear shape, and the neuron \nreaches its threshold using less average injected current.\n'

### Exercise 3: Compute $CV_{ISI}$ values

As shown above, the F-I curve becomes smoother while increasing the amplitude of the fluctuation ($\sigma$). In addition, the fluctuation can also change the irregularity of the spikes. Let's investigate the effect of $\mu=250$ with $\sigma=0.5$ vs $\sigma=3$.

Fill in the code below to compute ISI, then plot the histogram of the ISI and compute the $CV_{ISI}$. Note that, you can use `np.diff` to calculate ISI.

```python
def isi_cv_LIF(spike_times):
  """
  Calculates the inter-spike intervals (isi) and
  the coefficient of variation (cv) for a given spike_train

  Args:
    spike_times : (n, ) vector with the spike times (ndarray)

  Returns:
    isi         : (n-1,) vector with the inter-spike intervals (ms)
    cv          : coefficient of variation of isi (float)

  """

  if len(spike_times) >= 2:
    # Compute isi
    isi = np.diff(spike_times)
    # Compute cv
    cv = isi.std()/isi.mean()
  else:
    isi = np.nan
    cv = np.nan

  return isi, cv

pars = default_pars(T=1000.)
mu_gwn = 250
sig_gwn1 = 0.5
sig_gwn2 = 3.0

I_GWN1 = my_GWN(pars, mu=mu_gwn, sig=sig_gwn1, myseed=2020)
_, sp1 = run_LIF(pars, Iinj=I_GWN1)

I_GWN2 = my_GWN(pars, mu=mu_gwn, sig=sig_gwn2, myseed=2020)
_, sp2 = run_LIF(pars, Iinj=I_GWN2)

# Uncomment to check your function
isi1, cv1 = isi_cv_LIF(sp1)
isi2, cv2 = isi_cv_LIF(sp2)

my_hists(isi1, isi2, cv1, cv2, sig_gwn1, sig_gwn2)
```

## Interactive Demo: Spike irregularity explorer for different `sig_gwn`

In the above illustration, we see that the CV of inter-spike-interval (ISI) distribution depends on $\sigma$ of GWN. What about the mean of GWN, should that also affect the CV$_{\rm ISI}$? If yes, how? Does the efficacy of $\sigma$ in increasing the CV$_{\rm ISI}$ depend on $\mu$?

In the following interactive demo, you will examine how different levels of fluctuation $\sigma$ affect the CVs for different average injected currents ($\mu$).

```python
#@title

#@markdown Make sure you execute this cell to enable the widget!

my_layout.width = '450px'
@widgets.interact(
    sig_gwn=widgets.FloatSlider(0.0, min=0., max=10.,
                                step=0.5, layout=my_layout)
)

def diff_std_affect_fI(sig_gwn):
  pars = default_pars(T=1000.)
  I_mean = np.arange(100., 400., 20)
  spk_count = np.zeros(len(I_mean))
  cv_isi = np.empty(len(I_mean))

  isi_mean = np.zeros(I_mean.size)
  isi_var  = np.zeros(I_mean.size)

  for idx in range(len(I_mean)):
    I_GWN = my_GWN(pars, mu=I_mean[idx], sig=sig_gwn)
    v, rec_spikes = run_LIF(pars, Iinj=I_GWN)
    spk_count[idx] = len(rec_spikes)
    if len(rec_spikes) > 3:
      isi = np.diff(rec_spikes)
      cv_isi[idx] = np.std(isi) / np.mean(isi)

      isi_mean[idx] = np.mean(isi)
      isi_var[idx] = np.var(isi)

  # Plot the F-I curve i.e. Output firing rate as a function of input mean.
  plt.figure()
  plt.plot(I_mean[spk_count > 5], cv_isi[spk_count > 5], 'bo', alpha=0.5)
  plt.xlabel('Average injected current (pA)')
  plt.ylabel(r'Spike irregularity ($\mathrm{CV}_\mathrm{ISI}$)')
  plt.ylim(-0.1, 1.5)
  plt.grid(True)
  plt.show()

  # plt.figure()
  # plt.scatter(isi_mean, isi_var)
  # plt.xlabel('isi_mean')
  # plt.ylabel('isi_var')
```

    interactive(children=(FloatSlider(value=0.0, description='sig_gwn', layout=Layout(width='450px'), max=10.0, st…

### Try to answer the following:
- Does the standard deviation of the injected current affect the F-I curve in any qualitative manner?
- Why does increasing the mean of GWN reduce the $CV_{ISI}$?
- If you plot spike count (or rate) vs. $CV_{ISI}$, should there be a relationship between the two? Try out yourself.

```python
"""
1. Yes, it does. With DC input the F-I curve has a strong non-linearity but when
a neuron is driven with GWN, as we increase the $\sigma$ the non-linearity is
smoothened out. Essentially, in this case noise is acting to suppress the
non-linearities and render a neuron as a linear system.

2. (here is a short answer) When we increase the mean of the GWN, at some point
effective input mean is above the spike threshold and then the neuron operates
in the so called mean-driven regime -- as the input is so high all the neuron
is does is charge up to the spike threshold and reset. This essentially gives
almost regular spiking.

3. In an LIF, high firing rates are achieved for high GWN mean. Higher the mean,
higher the firing rate and lower the CV_ISI. So you will expect that as firing rate
increases, spike irregularity decreases. This is because of the spike threshold.
For a Poisson process there is no relationship between spike rate and spike
irregularity.
""";
```

---
# Section 4: Generation of Poisson type spike trains

*In the next tutorials, we will often use Poisson type spike train to explore properties of neurons and synapses. Therefore, it is good to know how to generate Poisson type spike trains.*

Mathematically, a spike train is a Point Process. One of the simplest models of a sequence of presynaptic pulse inputs is the Poisson process. We know that given temporal integration and refractoriness, neurons cannot behave as a Poisson Process, and Gamma Process gives a better approximation (*find out what might be the difference in the two processes*).
Here, however, we will assume that the incoming spikes are following Poisson statistics. A question arises about how to simulate a Poisson process. The generation of the Poisson process can be realized by at least two following ways:

- By definition, for a Poisson process with rate $\lambda$, the probability of finding one event in the time window with a sufficiently small length $\Delta t$ is $P(N = 1) = \lambda \Delta t$. Therefore, in each time window, we generate a uniformly distributed random variable $r \in [0,1]$ and generate a Poisson event when $r <\lambda \Delta t$. This method allows us to generate Poisson distributed spikes in an online manner.

- The interval $t_{k+1}-t_{k}$ between two Poisson events with rate $\lambda$ follows the exponential distribution, i.e., $P(t_{k+1}-t_{k}<t) = 1 - e^{\lambda t}$. Therefore, we only need to generate a set of exponentially distributed variables $\{s_k\}$ to obtain the timing of Poisson events $t_{k+1}=t_{k}+s_{k}$. In this method, we need to generate all future spikes at once.

Below, we use the first method in a function `Poisson_generator`, which takes arguments `(pars, rate, n, myseed)`.

```python
# @title
# @markdown Execute this cell to get a Poisson_generator function

def Poisson_generator(pars, rate, n, myseed=False):
  """
  Generates poisson trains

  Args:
    pars            : parameter dictionary
    rate            : noise amplitute [Hz]
    n               : number of Poisson trains
    myseed          : random seed. int or boolean

  Returns:
    pre_spike_train : spike train matrix, ith row represents whether
                      there is a spike in ith spike train over time
                      (1 if spike, 0 otherwise)
  """

  # Retrieve simulation parameters

  dt, range_t = pars['dt'], pars['range_t']
  Lt = range_t.size

  # set random seed
  if myseed:
      np.random.seed(seed=myseed)
  else:
      np.random.seed()

  # generate uniformly distributed random variables
  u_rand = np.random.rand(n, Lt)

  # generate Poisson train
  poisson_train = 1. * (u_rand < rate * (dt / 1000.))

  return poisson_train

```

```python
# we can use Poisson_generator to mimic presynaptic spike trains
pars = default_pars()
pre_spike_train = Poisson_generator(pars, rate=10, n=100, myseed=2020)
my_raster_Poisson(pars['range_t'], pre_spike_train, 100)

```

How do we make sure that the above spike trains are following Poisson statistics?
A Poisson process must have the following properties:
- The ratio of the mean and variance of spike count is 1
- Inter-spike-intervals are exponentially distributed
- Spike times are irregular i.e. $CV_{\rm ISI} = 1$
- Adjacent spike intervals are independent of each other.

---
# Summary

Congratulations! You've just built a leaky integrate-and-fire (LIF) neuron model from scratch, and studied its dynamics in response to various types of inputs, having:

- simulated the LIF neuron model

- driven the LIF neuron with external inputs, such as direct current, Gaussian white noise, and Poisson spike trains, etc.

- studied how different inputs affect the  LIF neuron's output (firing rate and spike time irregularity),

with a special focus on low rate and irregular firing regime to mimc real cortical neurons. The next tutorial will look at how spiking statistics may be influenced by a neuron's input statistics.

However, if you have extra time, follow the section below to explore a different type of noise input.

---
# Bonus 1: Orenstein-Uhlenbeck Process

When a neuron receives spiking input, the synaptic current is Shot Noise -- which is a kind of colored noise and the spectrum of the noise determined by the synaptic kernel time constant. That is, a neuron is driven by **colored noise** and not GWN.

We can model colored noise using the Ohrenstein-Uhlenbeck process - filtered white noise.

## Ornstein-Uhlenbeck (OU) current

We next study if the input current is temporally correlated and is modeled as an Ornstein-Uhlenbeck process $\eta(t)$, i.e., low-pass filtered GWN with a time constant $\tau_{\eta}$:

$$\tau_\eta \frac{d}{dt}\eta(t) = \mu-\eta(t) + \sigma_\eta\sqrt{2\tau_\eta}\xi(t).$$

**Hint:** An OU process as defined above has

$$E[\eta(t)]=\mu$$

and autocovariance

$$[\eta(t)\eta(t+\tau)]=\sigma_\eta^2e^{-|t-\tau|/\tau_\eta},$$

which can be used to check your code.

```python
# @title `my_OU(pars, mu, sig, myseed)`
# @markdown Ececute this cell to enable the OU process

def my_OU(pars, mu, sig, myseed=False):
  """
  Function that produces Ornstein-Uhlenbeck input

  Args:
    pars       : parameter dictionary
    sig        : noise amplitute
    myseed     : random seed. int or boolean

  Returns:
    I_ou       : Ornstein-Uhlenbeck input current
  """

  # Retrieve simulation parameters
  dt, range_t = pars['dt'], pars['range_t']
  Lt = range_t.size
  tau_ou = pars['tau_ou']  # [ms]

  # set random seed
  if myseed:
    np.random.seed(seed=myseed)
  else:
    np.random.seed()

  # Initialize
  noise = np.random.randn(Lt)
  I_ou = np.zeros(Lt)
  I_ou[0] = noise[0] * sig

  # generate OU
  for it in range(Lt-1):
    I_ou[it+1] = I_ou[it] + (dt / tau_ou) * (mu - I_ou[it]) + np.sqrt(2 * dt / tau_ou) * sig * noise[it + 1]

  return I_ou
```

### Interactive Demo: LIF Explorer with OU input
In the following, we will check how a neuron responds to a noisy current that follows the statistics of an OU process.

```python
# @title

# @markdown Remember to enable the widget by running the cell!

my_layout.width = '450px'
@widgets.interact(
  tau_ou=widgets.FloatSlider(10.0, min=5., max=20.,
                             step=2.5, layout=my_layout),
  sig_ou=widgets.FloatSlider(10.0, min=5., max=40.,
                             step=2.5, layout=my_layout),
  mu_ou=widgets.FloatSlider(190.0, min=180., max=220.,
                            step=2.5, layout=my_layout)
)

def LIF_with_OU(tau_ou=10., sig_ou=40., mu_ou=200.):
  pars = default_pars(T=1000.)
  pars['tau_ou'] = tau_ou  # [ms]

  I_ou = my_OU(pars, mu_ou, sig_ou)

  v, sp = run_LIF(pars, Iinj=I_ou)

  plt.figure(figsize=(12, 4))
  plt.subplot(121)
  plt.plot(pars['range_t'], I_ou, 'b', lw=1.0)
  plt.xlabel('Time (ms)')
  plt.ylabel(r'$I_{\mathrm{OU}}$ (pA)')
  plt.subplot(122)
  plot_volt_trace(pars, v, sp)
  plt.tight_layout()
  plt.show()
```

    interactive(children=(FloatSlider(value=10.0, description='tau_ou', layout=Layout(width='450px'), max=20.0, mi…

## Think!
- How does the OU type input change neuron responsiveness?
- What do you think will happen to the spike pattern and rate if you increased or decreased the time constant of the OU process?

```python
"""
Discussion:

In a limiting case, when the time constant of the OU process is very long and the
input current is almost flat, we expect the firing rate to decrease and neuron
will spike more regularly. So as OU process time we expect firing rate and
CV_ISI to decrease, if all other parameters are kept constant. We can also relate
the OU process time constant to the membrane time constant as the neuron membrane
does the same operation. This way we can link to the very first interactive demo.
""";
```

---
# Bonus 2: Generalized Integrate-and-Fire models

LIF model is not the only abstraction of real neurons. If you want to learn about more realistic types of neuronal models, watch the Bonus Video!

```python
#@title Video 3 (Bonus): Extensions to Integrate-and-Fire models
from IPython.display import YouTubeVideo
video = YouTubeVideo(id='G0b6wLhuQxE', width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=G0b6wLhuQxE


```python
import pyro
import pyro.distributions as dist
from pyro.infer import SVI, Trace_ELBO, TraceEnum_ELBO, config_enumerate, infer_discrete
pyro.enable_validation(True)

import torch as T
import torch.optim as O
import torch.distributions.constraints as constraints
import numpy as np
from tqdm import tqdm

from bagoftools.plotting import stem_hist

import scipy.stats as stats

import random

import matplotlib.pyplot as plt
import seaborn as sns

%matplotlib inline
%config InlineBackend.figure_format = 'retina'
sns.set()
```

## Basics

```python
d = dist.Normal(0, 1)
p = lambda x: d.log_prob(T.tensor(x)).exp().item()

p(0)
```

## Conditioning

```python
def scale(guess):
    weight = pyro.sample('weight', dist.Normal(guess, 1.0))
    return pyro.sample('measurement', dist.Normal(weight, 0.75))

conditioned_scale = pyro.condition(scale, data={'measurement': 9.5})
```

```python
# equivalent to conditioned_scale above
def scale_obs(guess):
    weight = pyro.sample('weight', dist.Normal(guess, 1.))
     # here we condition on measurement == 9.5
    return pyro.sample('measurement', dist.Normal(weight, 0.75), obs=9.5)
```

```python
# both will be equal to the obs value of `measurement`, for any input `guess`
conditioned_scale(guess=0), scale_obs(guess=0)
```

```python
@pyro.condition(data={'z': 1.0})
def gauss():
    z = pyro.sample('z', dist.Normal(0, 1))
    return z

gauss()
```

## Models and Inference
**Q:** how are vars involved / inter-related in a model (since they have a name)?

**A:** they are stored in `ParamStore` and are used during optimization to model distribution paramters.

---

- `weather()` specifies a joint probability distribution over two named random variables: `cloudy` and `temp`
- it defines a probabilistic model that we can reason about
- e.g. if I observe a temperature of 70 degrees, how likely is it to be cloudy?

```python
def weather():
    cloudy = pyro.sample('cloudy', dist.Bernoulli(0.3))
    cloudy = 'cloudy' if cloudy.item() == 1.0 else 'sunny'
    mean_temp = {'cloudy': 55.0, 'sunny': 75.0}[cloudy]
    scale_temp = {'cloudy': 10.0, 'sunny': 15.0}[cloudy]
    temp = pyro.sample('temp', dist.Normal(mean_temp, scale_temp))
    return cloudy, temp.item()

weather()
```

### Docs example

If `y` is observed to be 9.5,
then find `a,b` for `x ~ N(a, b)`

```python
# Suppose we observe that the measurement of an object was 9.5 lbs.
# What would have we guessed if we tried to guess it’s weight first?
# i.e. compute P(x | y = 9.5)

def model(loc):
    x = pyro.sample('x', dist.Normal(loc, 1.0))
    y = pyro.sample('y', dist.Normal(x, 0.75))
    return y

conditioned_model = pyro.condition(model, data={'y': T.tensor(9.5)})

def guide(loc):
    a = pyro.param('a', T.tensor(loc))
    b = pyro.param('b', T.tensor(1.0), constraint=constraints.positive)
    x = pyro.sample('x', dist.Normal(a, b))
    return x

pyro.clear_param_store()
svi = pyro.infer.SVI(model=conditioned_model,
                     guide=guide,
                     optim=pyro.optim.SGD({'lr': 0.001, 'momentum': 0.1}),
                     loss=pyro.infer.Trace_ELBO())

loc_prior = 8.5
losses, a, b  = [], [], []
num_steps = 5000
for t in tqdm(range(num_steps)):
    losses.append(svi.step(loc_prior))
    a.append(pyro.param('a').item())
    b.append(pyro.param('b').item())

print('a = ', pyro.param('a').item())
print('b = ', pyro.param('b').item())

plt.plot(losses)
plt.title('ELBO')
plt.xlabel('step')
plt.ylabel('loss');
plt.figure()
plt.plot(a)
plt.figure()
plt.plot(b)
```

```python
a = pyro.param('a').item()
b = pyro.param('b').item()

def f_():
    x = pyro.sample('x', dist.Normal(a, b))
    y = pyro.sample('y', dist.Normal(x, 0.75))
    return y

out = [f_() for _ in range(50000)]
hist(out, bins=96)
plt.plot([9.5]*14, np.linspace(0, 0.7, 14), c='r', linewidth=3)
plt.plot([a]*14, np.linspace(0, 0.7, 14), c='m', linewidth=3)
pass
```

---

## Simulate `observe`?

### Rejector

```python
class Wrapper(dist.Rejector):
    def __init__(self, underlying, log_accept, log_scale):
        super(Wrapper, self).__init__(underlying, log_accept, log_scale)

underlying = dist.Normal(0, 1)

x0 = T.tensor(0.0)

# this actually implements the predicate
log_accept = lambda x: (x > x0).float().log()

# CDF of predicate valid area: P(X > x) = 1 - P(X <= x)
log_scale = 1 - underlying.cdf(x0).log()

w = Wrapper(underlying, log_accept, log_scale)
xs = [pyro.sample('x', w) for _ in range(10000)]

plt.figure(figsize=(14, 4))
_, bins, _ = plt.hist(xs, bins=32, density=True, color='c')
plt.xlim([-3, 3])

pass
```

### Importance sampling
- we want to find the posterior $\left( x | y \sim \text{Bernoulli}(1) \right)$
- Bernoulli's success rate controls the predicate compliance
- `model()` gives $P(y | x)$
- $P(x|y) = P(y|x) \cdot P(x) \ / \ P(y)$

#### Observations
- `Empirical` (therefore `marginal`) does not have a `cdf`
- support = samples from posterior

```python
# Predicates
eq     = lambda a, b: float(np.isclose(a, b, atol=1e-3))
eq_any = lambda x, xs: T.tensor(any(map(lambda a: eq(a, x), xs))).float()
gt     = lambda x, y: (x > y).float()
lt     = lambda x, y: (x < y).float()
```

```python
def model():
    """
    s = 1
    when (x <= 0) -> y: 1-s
    when (x  > 0) -> y: s

    p(x > 0) ~ Bernoulli(s)
    """
#     1. observe(x > 0)
    x = pyro.sample('x', dist.Normal(0, 1))
    y = pyro.sample('y', dist.Bernoulli(1.0), obs=gt(x, 0))
    return y

#     2. obtain z = x + y (choosing a good prior)
#     x = pyro.sample('x', dist.Normal(1,1))
#     y = pyro.sample('y', dist.Normal(1,1))
#     z = pyro.sample('z', dist.Normal(1.5,1), obs=(x+y))
#     return z

#     3. y < x
#     x = pyro.sample('x', dist.Uniform(0, 1))
#     y = pyro.sample('y', dist.Uniform(0, 1))
#     z = pyro.sample('z', dist.Bernoulli(1.0), obs=lt(y, x))
#     return z

N = 5000

# perform posterior inference by importance sampling
posterior = pyro.infer.Importance(model, num_samples=N)

# construct marginal distribution
marginal  = pyro.infer.EmpiricalMarginal(posterior.run(), sites=['x'])

samples = [marginal.sample() for _ in range(2*N)]

plt.figure(figsize=(10,4))
plt.hist(samples, range=[-4, 4], bins=64, color='c', label='marginal', density=True, stacked=True)
plt.legend()
plt.show()
```

```python
xs = posterior.run()

for i, (tr, log_weight) in enumerate(zip(xs.exec_traces, xs.log_weights)):
    if i == 10: break
    x = tr.nodes['x']['value'].item()
    y = log_weight.exp().item()
    print(f'{x:8.5f}: {y:7.5f}')
```

$$
\begin{align}
P(x > 0) &= \frac{1}{\texttt{xs[xs > 0].size}} \\
P(x \leq 0) &= 0
\end{align}
$$

```python
p = lambda x: marginal.log_prob(T.tensor([x])).exp()

xs = np.array([x for x in marginal.enumerate_support()])
ps = np.array([p(x) for x in xs])

p_ = 1/xs[xs>0].size
print(f'P(x > 0) = {p_:9.7f}')

plt.figure(figsize=(12, 6))
plt.scatter(xs, ps, s=1, c='c')
plt.plot([xs.min(), xs.max()], [p_] * 2, linewidth=1, c='b', alpha=0.5)

# step prob:
# p(x  > 0) = 1 / xs[xs > 0].size
# p(x <= 0) = 0

pass
```


# Practical 4a: Recurrent Neural Networks (RNNs)

© Deep Learning Indaba. Apache License 2.0.

## Introduction

Feedforward models (eg deep MLPs and ConvNets) map fixed-size input-data (vectors of a fixed dimensionality) to their output labels. They're very powerful and have been successfully used for many tasks. However, a lot of data is not in the form of fixed-size vectors, but exists in the form of **sequences**. Language is one good example, where sentences are sequences of words. In some way, almost any data types can be considered as a sequence (for instance an image consists of a sequence of pixels, speech a sequence of phonemes, and so forth).

Recurrent neural networks (**RNNs**) were designed to be able to handle sequential data, and in this practical we will take a closer look at RNNs and then build a model that can generate English sentences in the style of Shakespeare!

## Learning Objectives
* Understand how RNNs model sequential data.
* Understand how the vanilla RNN is a generalization of feedforward models to incorporate sequential dependencies.
* Understand the issues involved when training RNNs.
* Know how to implement an RNN for time-series estimation (**regression**) and an RNN language model (character-level **classification**) in Tensorflow using Keras.

**IMPORTANT: Please fill out the exit ticket form before you leave the practical: https://forms.gle/vkLk6hHCwNrNsb8u6**

## Imports

```python
#@title Imports (RUN ME!) { display-mode: "form" }

#!pip install tensorflow-gpu==2.0.0-beta0 > /dev/null 2>&1
#!pip -q install pydot_ng > /dev/null 2>&1
#!pip -q install grapfrffhviz > /dev/null 2>&1
#!apt install graphviz > /dev/null 2>&1

import numpy as np
import tensorflow as tf
import math
import random
import ssl
import sys
import urllib

from IPython import display
import matplotlib.pyplot as plt
%matplotlib inline
```

##From Feedforward to Recurrent Models

### Intuition
RNNs generalize feedforward networks (FFNs) to be able to work with sequential data. Recall that FFNs take an input (e.g. an image) and immediately produce an output (e.g. a digit class), operating on all elements of the input simultaneously.

Here's an example of a function that computes the output of an FNN on an input `x` (the `W` and `b` arguments represent the weight and bias arrays):

```python
def ffn_forward(x, W_xh, W_ho, b_hid, b_out):

    # Compute activations on the hidden layer.
    hidden_layer = np.tanh(np.dot(W_xh, x) + b_hid)

    # Compute the (linear) output layer activations.
    output = np.dot(W_ho, hidden_layer) + b_out

    return output
```

> *NOTE*: The above cell is to show the structure of an FNN, so you don't need to run it.

RNNs, on the other hand, consider the data sequentially, and can remember what they have seen earlier in the sequence to help interpret or contextualize elements from later in the sequence when making predictions.

Here is a function that illustrations the idea. It takes the input sequence, and an initial state for the RNN, and then loops over the elements of the sequence, calculating new states for each one.

```python
def rnn_forward(data_sequence, initial_state):

    state = initial_state  # the variable state is updated at every time-step
    all_states = [state]  # save the states, so we can return them
    all_ys = [] # Used to save all predictions

    for x in data_sequence:

      # rnn_cell is a function (not shown) that it takes the current input x_t
      # and the previous state h, and produces an output y_t and a new state h.

      y, new_state = rnn_cell(x, state)

      all_states.append(new_state)
      all_ys.append(y)

      # Update state for the next time-step
      state = new_state

    return all_states, all_ys
```

> *NOTE*: The above cell is to show the structure of an RNN, so you don't need to run it. The definition of `rnn_cell` is not included, it will be explained below.

To understand the distinction between FFNs and RNNs, imagine we want to label words as the part-of-speech categories that they belong to: E.g. for the input sentence "I want a duck" and "He had to duck", we want our model to predict that duck is a `Noun` in the first sentence and a `Verb` in the second. To do this successfully, the model needs to be aware of the surrounding context. However, if we feed a FFN model only one word at a time, how could it know the difference? If we want to feed it all the words at once, how do we deal with the fact that sentences are of different lengths?

RNNs solve this issue by processing the sentence word-by-word, and maintaining an internal **state** summarizing what it has seen so far. This applies not only to words, but also to phonemes in speech, or even, as we will see, elements of a time-series.

### Unrolling the network

Imagine we are trying to classify a sequence $X = (x_1, x_2, \ldots, x_T)$ into labels $y$ (for now, let's keep it abstract). After running the `rnn_forward()` function of our RNN defined above on $X$, we would have a list of internal states and outputs of the model at each sequence position. This process is called **unrolling or unfolding in time**, because you can think of it as unrolling the *computations* defined by the RNN loop over the inputs at each position of the sequence.  RNNs are often used to model **time series data** (which we will do in this practical), and therefore these positions are referred to as **time-steps**, and hence we call this process "unrolling over time". See the visualization below:

We can therefore think of an RNN as a composition of identical feedforward neural networks (with replicated/tied weights), one for each moment or step in time.

These feedforward functions that make up the RNN (i.e. our `rnn_cell` function above) are typically referred to as **cells**, and the only requirement for this function is that the cell function needs to be a differentiable function that can map an input and a state vector to an output and a new state vector. What we have shown above is called the **vanilla RNN**, but there are many more possibilities. One of the most popular variants is called the **Long Short-Term Memory (LSTM)** cell, which we'll use later to create our Shakespeare language model.

### Putting this together

In the feedforward models we've seen before, the input $x$ is mapped to an intermediate hidden layer $h$ as follows:

\begin{equation}
  h = \sigma(\underbrace{W_{xh}x}_\text{current input (per-example)} + b)
\end{equation}

where $\sigma$ is some non-linear activation function like ReLU or tanh.  We can then make a prediction $\hat{y} = \sigma(W_{hy}h + b)$ based on $h$, or we can add another layer, etc.  **NOTE**: We use the weight subscript $W_{xz}$ to indicate a mapping from layer $x$ to layer $z$.

RNNs generalize this feedforward idea to a sequence of inputs $X = {x_1, x_2, ...}$ by maintaining a sequence of state vectors $h_t$, one for every time-step $t$. The simplest form of such an RNN looks very similar to the equation for a single feedforward layer:

\begin{equation}
  h_t = \sigma(\underbrace{W_{hh}h_{t-1}}_\text{previous state} + \underbrace{W_{xh}x_t}_\text{current input (per time-step)} + b)
\end{equation}

These states $h_t$ can then be used for various purposes, as we describe below.

Feedforward models map one input to one output. On the other hand, RNNs can give **many-to-many** (many inputs, many labels), **many-to-one**, and **one-to-many** mappings. Examples of these tasks include machine translation, sentiment analysis, and image captioning, respectively. For example, we could use each $h_t$ to predict the part of speech for that time-step ($y_t= \sigma(W_{hy}h_t+b)$) or we could use the last state $h_T$ to predict the topic of the whole document. Which task the RNN performs is based on the training data (of course)! We can visualize these as follows (from this [excellent blog post](http://karpathy.github.io/2015/05/21/rnn-effectiveness/) by Andrej Karpathy):

**QUESTIONS**
* How are FFNs and RNNs **similar**?
* How are they **different**?
* Why do we call RNNs "recurrent"?
* Can you think of a one-to-many task?

##Modeling General Time-Series

We will train an RNN to model a time-series as a first step. A **time-series** is a series of data-points ordered over discrete time-steps. Examples include the hourly temperature of Stellenbosch over a month or a year, the market price of some asset (like a company's stock) over time, and so forth. We will generate a **sinusoidal time-series** (with or without noise) as a toy example, and then train a tiny RNN model with only 5 parameters on this data.

###Create some artificial data

```python
#@title Create sinusoidal data {run: "auto"}
steps_per_cycle = 20 #@param { type: "slider", min:1, max:100, step:1 }
number_of_cycles = 176 #@param { type: "slider", min:1, max:1000, step:1 }
noise_factor = 0.1 #@param { type: "slider", min:0, max:1, step:0.1 }
plot_num_cycles = 23 #@param { type: "slider", min:1, max:50, step:1 }

seq_len = steps_per_cycle * number_of_cycles
t = np.arange(seq_len)
sin_t_noisy = np.sin(2 * np.pi / steps_per_cycle * t + noise_factor * np.random.uniform(-1.0, +1.0, seq_len))
sin_t_clean = np.sin(2 * np.pi / steps_per_cycle * t)

upto = plot_num_cycles * steps_per_cycle
fig = plt.figure(figsize=(15,3))
plt.plot(t[:upto], sin_t_noisy[:upto])
plt.title("Showing first {} cycles.".format(plot_num_cycles))
plt.show()

#both = np.column_stack((t, sin_t_noisy))
#print("both.shape = {}".format(both.shape))

#print("both[:steps_per_cycle, :steps_per_cycle]")
#print(both[:steps_per_cycle,:steps_per_cycle])
```

**TASK**: Adjust the parameters above to generate data with different properties.

Now we pack the data into train and test batches. Note that while RNNs can in theory learn the dependencies across all inputs received so far (using an algorithm called **backpropagation through time**, or BPTT; see the Aside box below), in practice they are trained using an algorithm called **truncated BPTT** where we truncate the inputs to only the last $T$ symbols (this is the `truncated_seq_len` variable below).

**QUESTIONs**:
* What issues can you think may arise by truncating the training data in this way?
* Despite these issues, why do you think it might be necessary to do this?

```python
#@title Pack truncated sequence data {run: "auto"}

def pack_truncated_data(data, num_prev = 100):
    X, Y = [], []
    for i in range(len(data) - num_prev):
      X.append(data[i : i + num_prev])
      Y.append(data[i + num_prev])
    # NOTE: Keras expects input data in the shape (batch_size, truncated_seq_len, input_dim)
    # We have only one real-valued number per time-step, so we therefore expand
    # the last dimension from (batch_size, truncated_seq_len) to
    # (batch_size, truncated_seq_len, 1).
    X, Y = np.array(X)[:,:,np.newaxis], np.array(Y)[:,np.newaxis]
    return X, Y

# We only consider this many previous data points
truncated_seq_len = 2 #@param { type: "slider", min:1, max:10, step:1 }
test_split = 0.25  # Fraction of total data to keep out as test data

# We use only the sin(t) values, and discard the time values
data = sin_t_noisy
data_len = data.shape[0]
num_train = int(data_len * (1 - test_split))

train_data = data[:num_train]
test_data = data[num_train:]

X_train, y_train = pack_truncated_data(train_data, num_prev=truncated_seq_len)
X_test, y_test = pack_truncated_data(test_data, num_prev=truncated_seq_len)

print("Generated training/test data with shapes\nX_train: {}, y_train: {}\nX_test: {}, y_test: {}. ".format(
    X_train.shape, y_train.shape, X_test.shape, y_test.shape))

```

**NOTE**: We reshape the training data into (batch_size, truncated_seq_len, 1) and (batch_size, 1) arrays.

### Intermediate optional extra reading: (Truncated) Backpropagation-through-Time and Vanishing and Exploding Gradients

RNNs model sequential data, and are designed to capture how ***outputs*** at the current time step are influenced by the ***inputs*** that came before them. This is referred to as **long-range dependencies**. At a high level, this allows the model to remember what it has seen so far in order to better contextualize what it is seeing at the moment (think about how knowing the context of the sentence or conversation can sometimes help one to better figure out the intended meaning of a misheard word or ambiguous statement). It is what makes these models so powerful, but it is also what makes them so hard to train!

The most well-known algorithm for training RNNs is called **back-propagation through time (BPTT**; there are other algorithms). BPTT conceptually amounts to unrolling the computations of the RNN over time, computing the errors, and backpropagating the gradients through the unrolled graph structure.  Ideally we want to unroll the graph up to the maximum sequence length, however in practice, since sequence lengths vary and memory is limited, we only end up unrolling sequences up to some length $T$. This is called **truncated BPTT**, and is the most used variant of BPTT.

At a high level, there are two main issues when using (truncated) BPTT to train RNNs:

* Having shared ("tied") recurrent weights ($W_{hh}$) mean that **the gradient on these weights at some time step $t$ depends on all time steps up to time-step $T$**, the length of the full (truncated) sequence. This also leads to the **vanishing/exploding gradients** problem, which we'll explain below.

* **Memory usage grows linearly with the total number of steps $T$ that we unroll for**, because we need to save/cache the activations at each time-step (look at the Python code above to convince yourself of this).  This matters computationally, since memory is a limited resource. It also matters statistically, because it puts a limit on the types of dependency that the model can successfully learn, by preventing it from correcting errors that stem from an input more than $T$ steps ago.

**NOTE**: Think about that last statement and make sure you understand those 2 points.

BPTT is very similar to the standard back-propagation algorithm. Key to understanding the BPTT algorithm is to realize that gradients on the non-recurrent weights (weights of a per time-step classifier that tries to predict the part-of-speech tag for each word for example) and recurrent weights (that transform $h_{t-1}$ into $h_t$) are computed differently:

* The gradients of **non-recurrent weights** ($W_{hy}$) depend only on the error at that time-step, $E_t$.
* The gradients of **recurrent weights** ($W_{hh}$) depend on all previous time-steps up to maximum length $T$.

The first point is fairly intuitive: predictions at time-step $t$ is related to the loss of that particular prediction.

The second point will be explained in more detail in the lectures (see also [this great blog post](http://www.wildml.com/2015/10/recurrent-neural-networks-tutorial-part-3-backpropagation-through-time-and-vanishing-gradients/)), but briefly, this can be summarized in these equations:

1. The **current** state is a function of the **previous** state and the current input: $h_t = \sigma(W_{hh}h_{t-1} + W_{xh}x_t)$
2. The gradient of the loss $E_t$ at time $t$ on $W_{hh}$ is a function of the current hidden state and model predictions $\hat{y}_t$ at time t:
$\frac{\partial E_t}{\partial W_{hh}} = \frac{\partial E_t}{\partial \hat{y}_t}\frac{\partial\hat{y}_t}{\partial h_t}\frac{\partial h_t}{\partial W_{hh}}$
3. Substituting (1) into (2) results in a **sum over all previous time-steps**:
$\frac{\partial E_t}{\partial W_{hh}} = \sum\limits_{k=0}^{t} \underbrace{\frac{\partial E_t}{\partial \hat{y}_t}\frac{\partial\hat{y}_t}{\partial h_t}\frac{\partial h_t}{\partial h_k}\frac{\partial h_k}{\partial W_{hh}}}_\text{product of gradient terms}$

The problem is that $\frac{\partial h_t}{\partial h_k} = \Pi_j \frac{\partial h_j}{\partial h_{j-1}}$ for j from $k + 1$ to $t$.  Because of this **repeated multiplicative interaction**, as the sequence length $t$ gets longer, the gradients themselves can get diminishingly small (**vanish**) or grow too large and result in numeric overflow (**explode**). This has been shown to be related to the norms of the recurrent weight matrices being less than or equal to 1. Intuitively, it works very similar to how multiplying a small number $v<1.0$ with itself repeatedly can quickly go to zero, or conversely, a large number $v>1.0$ could quickly go to infinity; only this is for matrices.

###Build a tiny RNN in Keras

Building an RNN in Keras is quite simple. We simply chain the layers together as follows:

```python
def define_model(truncated_seq_len):

    input_dimension = 1
    hidden_dimension = 1
    output_dimension = 1

    model = tf.keras.models.Sequential()
    model.add(tf.keras.layers.SimpleRNN(
        # We need to specify the input_shape *without* leading batch_size (it is inferred)
        input_shape=(truncated_seq_len, input_dimension),
        units=hidden_dimension,
        return_sequences=False,
        name='hidden_layer'))
    model.add(tf.keras.layers.Dense(
        output_dimension,
        name='output_layer'))

    model.compile(loss="mean_squared_error",
                  optimizer=tf.compat.v1.train.AdamOptimizer(learning_rate=1e-3))

    return model

```

**NOTE**: We're building an RNN for **regression**. We therefore use a linear layer (which outputs real-valued numbers) at the output with the "*mean_squared_error*" loss function.

```python
model = define_model(truncated_seq_len = X_train.shape[1])
model.summary()
```

**NOTE**: You need to re-run the above cell every time after training to reset the model weights!

###Train the tiny RNN
Now let's train the model. This may take a few minutes (it takes much longer if you increase `truncated_seq_len`). Set `verbose=1` **before** you run the cell to see the intermediate output as the model is training. Set it to 0 if you don't want any output.

```python
''' SOLUTION TO ONE OF TASKS [DELETE]
patience = 5
train_history = model.fit(X_train, y_train, batch_size=600, epochs=1000,
                          verbose=1, validation_split=0.05,
                          callbacks=[tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=patience, verbose=1)])
'''
```

```python
train_history = model.fit(X_train, y_train, batch_size=600, epochs=1000,
                          verbose=1, validation_split=0.05)
```

Let's visualize the training and validation losses.

```python
plt.figure(figsize=(15,5))

for label in ["loss","val_loss"]:
    plt.plot(train_history.history[label], label=label)

plt.ylabel("loss")
plt.xlabel("epoch")
plt.title("The final validation loss: {}".format(train_history.history["val_loss"][-1]))
plt.legend()
plt.show()
```

Finally, let's look at the parameters for the trained model.

```python
for layer in model.layers:
  print("{}, {}".format(layer.name, layer.get_weights()))
```

**QUESTION**:
* Relate the above weights to the terms in the equation for the vanilla RNN we saw earlier, namely:
  * input-to-hidden $W_{xh}$,
  * hidden-to-hidden $W_{hh}$,
  * hidden-to-output weights $W_{hy}$
  * recurrent and out biases.

###Make predictions using the trained model

```python
y_pred = model.predict(X_test[:100])
plt.figure(figsize=(19,3))

plt.plot(y_test[:100], label="true")
plt.plot(y_pred, label="predicted")
plt.legend()
plt.show()
```

**YOUR TASKS**:
* [**ALL**] Change the learning rate up to 5 orders of magnitude larger and smaller and retrain. What happens when it is too large? What happens when it is too small?
* [**ALL**] Change the `SimpleRNN` to `GRU`.
  * You can read more about LSTMs in [this blog post](https://colah.github.io/posts/2015-08-Understanding-LSTMs/) by Chris Olah.
  * What is the effect on the number of parameters? Can you explain why? Now do the same for `LSTM`.
* [**INTERMEDIATE**] Note that the loss does not decrease much after around epoch 400. Add "Early Stopping with patience" to the `model.fit()` function to stop it from training beyond this point. **Hint**: Look at tf.keras.callbacks.
  * *Early stopping* is a technique where we stop training the model once it's performance on validation data stops improving, and is a very common approach to prevent overfitting. Early stopping *with patience* means as soon as the model starts doing worse on validation we wait for at least `patience` more evaluations before stopping training, and if it improves within that time, we reset the counter. The patience parameter is a way to ensure we don't stop by accident, as the validation loss can fluctuate randomly from epoch to epoch.

##Generating Shakespeare

Now let's build an RNN language model to generate Shakespearian English! A language model is trained to assign high probabilities to sequences of words or sentences that are well formed, and low probabilities to sequences which are not realistic. When the model is trained, one can use it to *generate* data that is similar to the training data.

Our data is now sequences of discrete symbols (characters). But neural networks operate in continuous spaces, and so we need to take the discrete language data, and **embed** it in a continuous space. To do this, we'll simply break up the data into sequences of characters, and represent each character using a learned vector. This is a standard trick for processing text using neural networks.

### Download and Preprocess the Data

We first download the data and examine what it looks like:

```python
context = ssl._create_unverified_context()
shakespeare_url = 'https://cs.stanford.edu/people/karpathy/char-rnn/shakespeare_input.txt'

data = urllib.request.urlopen(shakespeare_url, context=context)
all_text = data.read().lower().decode('utf8')

print("Downloaded Shakespeare data with {} characters.".format(len(all_text)))
print("FIRST 1000 CHARACTERS: ")
print(all_text[:1000])
```

```python
training_text = all_text[:1000000] # Keep only the first 1 million characters
```

We now preprocess the text data as follows:

1. Extract the vocabulary of all `vocab_size` unique characters appearing in the data.

2. Assign each character a unique integer id in `0 <= id < vocab_size`. This is so we can map the characters to unique embedding vectors. This is a common way to map discrete inputs to continuous vectors that neural networks can work with. See e.g. this [blog post](https://www.tensorflow.org/tutorials/representation/word2vec), or [this one](http://ruder.io/word-embeddings-1/) for more information.

3. Split the data into sequences ("windows") of `max_len` characters (the input to the model) followed by the next character as target. E.g. using `max_len=5` the sentence "I saw a cat" (11 characters) will get split into "I saw" and /space/, "/space/saw/space/" and "a", "saw a" and /space/, etc. To add some variation, we skip `step` characters between each sequence (i.e. we use a "sliding window of `max_len` with stride `step`").

```python
max_len = 30  # We only consider this many previous data points (characters)
step = 3 # We start a new training sequence every `step` characters
sentences = [] # This holds our extracted sequences
next_chars = [] # This holds the targets (the follow-up characters)

chars = sorted(list(set(training_text)))  # List of unique characters in the corpus
vocab_size = len(chars)
print('Number of unique characters: ', vocab_size)
print(chars)

# Construct dictionaries mapping unique characters to their index in `chars` and reverse
char2index = dict((c, chars.index(c)) for c in chars)
index2char = dict((chars.index(c), c) for c in chars)
```

Now we encode the training data by mapping each character to its unique integer id.

```python
for i in range(0, len(training_text) - max_len, step):
    sentences.append([char2index[s] for s in training_text[i: i + max_len]])
    next_chars.append([char2index[training_text[i + max_len]]])

print('Number of extracted sequences:', len(sentences))
```

This yields the following numpy arrays:

```python
X, Y = np.array(sentences, dtype=np.int64), np.array(next_chars, dtype=np.int64)
X.shape, Y.shape
```

Let's take a look at the first example.

```python
print("X[0].shape = {}, Y[0].shape = {}".format(X[0].shape, Y[0].shape))
print("X[0]: ", X[0])
print("Y[0]: ", Y[0])
```

###Build an RNN language model

A **language model** estimates a probability distribution over sequences $\mathbb{x}_{1:N} = (x_1, x_2, ..., x_N)$ by breaking up the full joint probability into a sequence of conditional probabilities using the **[chain-rule of probability](https://en.wikipedia.org/wiki/Chain_rule_(probability))**:

\begin{align}
 p(\mathbb{x}_{1:N}) &= p(x_1) \cdot p(x_2 | x_1) \cdot p(x_3 | x_2, x_1) \ldots \\
 &= \Pi_1^N p(x_i | \mathbb{x}_{1:i-1})
\end{align}

In other words, to model the probability of the phrase "*i saw a cat*" at the character level, the model learns to estimate the probabilities for p(i), p(/space/| i), p(c | i, /space/), and so forth, and multiplies them together.

There are many different ways in which to estimate these individual probabilities. But one particularly effective way is to use an RNN! To do this, we'll therefore be modeling the  $p(x_i | \mathbb{x}_{1:i-1})$ terms using an RNN conditioned on $\mathbb{x}_{1:i-1}$.

* We model these probabilities at the character-level, so we'll use an `Embedding` layer as the first layer of our model to map the discrete character id's to real-valued embedding vectors.
* Next, the RNN-core will map these sequences of character embeddings to a probability distribution over all characters $p(x_i | \mathbb{x}_{1:i-1}) \in \mathbb{R}^\textrm{vocab_size}$ at every step of the sequence. To do this, the RNN will map the embeddings to a sequence of *hidden states*. We will then use a `Dense` layer to map from the RNN hidden state to an output distribution over the total number of characters using a [`softmax`](https://en.wikipedia.org/wiki/Softmax_function) activation.

We can do this with a few lines of code:

```python
embedding_dim = 32   # Map each character to a unique vector of this dimension
vocab_size = len(chars)

model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Embedding(
  vocab_size, embedding_dim,
  input_length=max_len,
  embeddings_initializer=tf.keras.initializers.TruncatedNormal))
model.add(tf.keras.layers.LSTM(
  128,
  input_shape=(max_len, embedding_dim),  # NB: Ensure this matches the embedding_dim!
  dropout=0.1,  # input-to-hidden drop-probability
  recurrent_dropout=0.2))  # hidden-to-hidden drop-probability
model.add(tf.keras.layers.Dense(vocab_size, activation='softmax'))

model.summary()
```

Once we have a model that can map sequence of characters to a probability distribution over the next character in the sequence, we can train it using **maximum likelihood** on the training set to find the model parameters which maximizes the probability of the training data. Again, this is very simple to do by choosing an optimizer and selecting the `sparse_categorical_crossentropy` loss function:

```python
optimizer = tf.keras.optimizers.Adam()
loss='sparse_categorical_crossentropy'

model.compile(loss=loss, optimizer=optimizer)
```

### Optional extra reading: RNN vs GRU vs LSTM

Our language model above is using an RNN variant called the LSTM (long short-term memory). The LSTM solves a big problem with the vanilla RNN: vanishing and exploding gradients. It does this by allowing gradients to flow backwards through the layer unimpeded. As a result, the LSTM is very popular for sequence tasks. The key to the success of the LSTM is that it maintains an internal **cell state** ($\mathbf{c}_t$) which can be thought of as a 'memory' for the LSTM. The cell state provides the path along which the gradients can easily flow. The LSTM can control the contents of the cell state using three **gates**:

* The **forget** gate allows the LSTM to remove contents from the cell state.
* The **input** and **memory** gates control what information is added to the cell state from the input data.

Finally, an **output** gate controls which part of the cell state we output at each time step.

A somewhat simplified version of the LSTM is the GRU (gated recurrent unit), which has also been used successfully in many applications and has only three gates.

Looking at the diagrams below, which show the vanilla RNN, LSTM, and GRU, respectively, work with your partner to try to understand the description above and then answer the following **questions:**

1. What does each of the gates in the GRU do?

2. How does the GRU address the vanishing/exploding gradients problem? In other words, how does it allow the gradient to pass through unimpeded?

If you get stuck or want to know more, you can read more about the LSTM, GRU, and other variants [here](http://colah.github.io/posts/2015-08-Understanding-LSTMs/).

###Helper functions

```python
def sample_with_temp(preds, temperature=1.0):
  preds = np.asarray(preds).astype('float64')
  preds = np.log(preds) / temperature
  exp_preds = np.exp(preds)
  preds = exp_preds / np.sum(exp_preds)
  probas = np.random.multinomial(1, preds, 1)
  return np.argmax(probas)
```

```python
def shift_and_append(test_arr, next_item):
  '''Returns a copy of test_arr with items shifted one position to the left and
     next_item appended.
  '''
  tmp = np.empty_like(test_arr)
  tmp[:,:-1] = test_arr[:,1:]
  tmp[:,-1] = next_item
  return tmp

## TEST the above function:
test_arr = np.array([[1,2,3,4]])

print("test_arr = {}".format(test_arr))
test_arr = shift_and_append(test_arr, 5)
print("roll_arr(test_arr, 5) = {}".format(test_arr))
```

```python
def sample_from_model(model,
                      num_generate=400,
                      prev_text=None,  # the text used to condition the model
                      temperatures=[0.2, 0.5, 1.0, 1.2]):

    if not prev_text:
      # Select a text seed at random
      start_index = random.randint(0, len(training_text) - max_len - 1)
      while ((start_index < (len(training_text) - max_len - 1)) and (
        training_text[start_index - 1] is not ' ')):
        start_index += 1  # Advance to beginning of new word
      prev_text = training_text[start_index: start_index + max_len]

    if len(prev_text) != max_len:
      print("`prev_text` must be of length `max_len`.")
      return

    print('GENERATING TEXT WITH SEED: \n"' + prev_text + '"')
    prev_text_arr = np.array(
      [[char2index[c] for c in prev_text]], dtype=np.int64)

    for temp in temperatures:
        print('==TEMPERATURE:', temp)
        sys.stdout.write(prev_text)

        # Start with the same sampled text for all temperatures
        generated_text = prev_text
        generated_text_arr = prev_text_arr

        # Now generate this many characters
        for i in range(num_generate):

            # Get the output softmax given the conditioning text
            #prev_text = generated_text_enc[np.newaxis,:]
            preds = model.predict(generated_text_arr, verbose=0)[0]

            next_index = sample_with_temp(preds, temp)
            next_char = index2char[next_index]
            generated_text += next_char
            generated_text = generated_text[1:]

            # Left-shift and add into encoded array
            generated_text_arr = shift_and_append(generated_text_arr, next_index)

            sys.stdout.write(next_char)
            sys.stdout.flush()
        print()
```

###Train the model

Let's train the model! The code below will train the model on a subset of the available data, and then generate from the model every `sample_every` number of batches.

To generate from the model, we use `model.predict()` on a sequence of `max_len` conditioning characters to produce an output distribution over `vocab_size` characters. We then sample one character from this distribution and shift everything up by one and append the new characters. By repeating this, we can generate text from the (partially-trained) model.

**NOTE**:
* It takes a while to train a model that starts generating anything resembling the Shapespeare text! In general it should start getting the rough structure in place around the 100K training example mark (examples, not batches). But to generate any meaningful words will need several hundred thousand examples.
* We sample with *temperature*. This is a way to sharpen or flatten the probabilities produced by the model. By lowering the temperature, we emphasize the modes of the predicted distribution, and by increasing the temperature, we flatten the modes (tends towards uniform). Higher temperatures therefore encourage the model to be more 'creative', instead of always choosing the most likely next character.

```python
batch_size = 128
total_num_batches = X.shape[0] // batch_size
sample_every = 256  # Train on this many batches, then generate something

print("Training on {} batches in total.".format(total_num_batches))

for cur_batch in range(0, total_num_batches, sample_every):
    print('TRAINING ON BATCH {} to {} (example {} to {})'.format(
      cur_batch, cur_batch + sample_every,
      cur_batch * batch_size, (cur_batch + sample_every) * batch_size)
    )

    X_batch = X[batch_size * cur_batch : batch_size * (cur_batch + sample_every), :]
    Y_batch = Y[batch_size * cur_batch : batch_size * (cur_batch + sample_every), :]

    '''
    # Show the first 5 examples to make sure we're not training on garbage
    print("X_batch.shape = {}".format(X_batch.shape))
    print("Y_batch.shape = {}".format(Y_batch.shape))
    print("FIRST 5 EXAMPLES:")
    for num in range(5):
      in_seq = [index2char[int(indx)] for indx in np.nditer(X_batch[num, :])]
      next_char = index2char[Y_batch[num, 0]]
      print(str(num) + '. ' + ''.join(in_seq) + '-->' + next_char)
    '''

    model.fit(X_batch, Y_batch,
              batch_size=batch_size,
              epochs=1,
              verbose=1)

    print("GENERATING SOME RANDOM TEXT FROM THE MODEL")
    sample_from_model(model)
```

**NOTE**: Even after training has stopped you can still generate from the (partially trained) model as follows:

```python
my_text = "       the meaning of life is:"  # Needs to be max_len characters
print(len(my_text))
sample_from_model(model, prev_text=my_text)
```

###IMPORTANT NOTES
* Even if you stop training the model weights are persistant. If you resume training it will start where you left off.
* To reset the weights, you need to recompile the model.
* Sampling is **stochastic** (random), so you'll get new outputs every time you rerun the sampling code.

### YOUR TASKS:
* [**ALL**] Read the generations from your model in a funny voice to your neighbour.
* [**ALL**] Increase `max_len` and regenerate the data and retrain the model.
 * What's the effect on training speed as you double `max_len`. Can you explain why?
 * Do you notice any effect on the quality of the model? Can you explain why?
* [**ALL**] Set the max_len to be roughly the length of half a word; one word; two words... What kind of samples do these models generate? Explain how they go wrong.
* [**ALL**] Change `embedding_dim` and the hidden size of the LSTM and observe the effect on training speed and quality.
* [**INTERMEDIATE**] Change the dropout rates & retrain the model.
  * What types of dropout do we get for recurrent models?
  * What's the effect on the text quality?
* [**ADVANCED**] Implement the "teacher forcing" training methodology, where the net must predict the entire output sequence shifted forward by one character, instead of just the next character. Compare the output of a model trained with teacher forcing versus the per-character model, given a similar training time. Is it fair to say that teacher forcing is a more efficient training methodology?

**IMPORTANT: Please fill out the exit ticket form before you leave the practical: https://forms.gle/vkLk6hHCwNrNsb8u6**

##Further reading

* https://distill.pub/2016/augmented-rnns/
* https://distill.pub/2017/ctc/
* https://algotravelling.com/en/machine-learning-fun-part-5/
* http://www.wildml.com/2015/09/recurrent-neural-networks-tutorial-part-1-introduction-to-rnns/