# A Tutorial of Variational Latent Gaussian Process Model

This tutorial shows how to use the code to reproduce part of the result in the paper.

Firstly import modules.

```python
import os
from os import path as op

import numpy as np
from scipy import stats
from scipy import linalg

import vlgp
from vlgp import util, simulation
```

Then import and set up graphics. Borrow the palette from seaborn.

```python
import matplotlib as mpl
%matplotlib inline
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
```

Create the paths to the data and ouput directories. These two subdirectories are expected to exist in current directory.

```python
datapath = op.abspath('../data')
outputpath = op.abspath('../output')
```

## Lorenz Dynamics

We simulate a dataset driven by 3-dimensional Lorenz dynamics defined by the following differential equations,
\begin{align}
    x_1' &= \sigma(x_2 - x_1)     \\
    x_2' &= x_1(\rho - x_3) - x_2 \\
    x_3' &= x_1 x_2 - \beta x_3.
\end{align}

Each sample consists of 10 trials of 50 spike trains. Each trial contains 1000 time bins. The discrete latent dynamics are sampled with the time step of 0.0015. The parameters are set to $\sigma=10$, $\rho=28$ and $\beta=2.667$. We normalize the dynamics and discard the first 2000 points to get stable result.

```python
ntrial = 10  # number of trials
nbin = 1000  # number of time bins of each trial
nneuron = 50 # number of neurons (spike trains)
dim = 3 # latent dimension
```

To reproduce the same results as that in the paper, set the random seed and simulate 5 samples.

```python
np.random.seed(0)
skip = 2000
lorenz = simulation.lorenz(skip +  ntrial * nbin, dt=1.5e-3, s=10, r=28, b=2.667, x0=np.random.random(3) * 2)
lorenz = (lorenz - lorenz.mean(axis=0)) / linalg.norm(lorenz, axis=0, ord=np.inf)
x = lorenz[skip:, :].reshape((ntrial, nbin, dim))  # latent dynamics in proper shape
```

Plot the first trial.

```python
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.plot(*lorenz.T)
plt.axis("off")
plt.show()
plt.close()
```

Then simulate spike trains with 10-step history filter given the simulated latent dynamics. The elements of loading matrix is randomly generated from $(1, 2)$ with random signs. The base firing rate is 15Hz.

The simulation function `simulation.spike` gives spike trains, design matrix of regression part and the true firing rates. We sort the loading matrix by row.

```python
np.random.seed(0)
bias = np.log(15 / nbin)  # log base firing rate
a = (np.random.rand(dim, nneuron) + 1) * np.sign(np.random.randn(dim, nneuron)) # loading matrix
b = np.vstack((bias * np.ones(nneuron), -10 * np.ones(nneuron), -10 * np.ones(nneuron), -3 * np.ones(nneuron),
                  -3 * np.ones(nneuron), -3 * np.ones(nneuron), -3 * np.ones(nneuron), -2 * np.ones(nneuron),
                  -2 * np.ones(nneuron), -1 * np.ones(nneuron), -1 * np.ones(nneuron)))  # regression weights

y, _, rate = simulation.spike(x, a, b)
sample = dict(y=y, rate=rate, x=x, alpha=a, beta=b)
util.save(sample, op.join(datapath, 'lorenz.npy'))  # store the simulation
```

Load the first sample and plot the spike trains.

```python
# Raster plot
plt.figure()
plt.spy(sample['y'][0, ...].T, aspect='auto')
plt.show()
plt.close()
```

### Model Fitting

Now let us do inference on the first sample. Firstly we need to set the random seed in order to get reproducible result because the algorithm does subsampling in the hyperparameters optimization steps.

The `fit` function requires the observation array, list of channel types, initial values of hyperparameters, lag and rank of prior covariance decomposition. The observation array is supposed to be in the shape of (trial, bin, channel). The 'spike' channel indicates it is a spike train (Poisson).

The function also needs the true values of latent and parameters to calculate the angle between subspaces and other statistics.

The rest arguments are options controlling the behavior of algorithm.
* niter: max number of iterations
* tol: relative tolerance for checking convergence
* verbose: display info in every iteration
* learn_post: optimize the posterior
* learn_param: optimize the parameters
* learn_hyper: optimize hyperparamters
* nhyper: optimize hyperparameters every nhyper iteration

We can save the fitted model in HDF5 format. Note that HDF5 does not support array of strings and thus remove it before saving.

```python
np.random.seed(0)

trials = [{'ID': i, 'y': y} for i, y in enumerate(sample['y'])]  # make trials

fit = vlgp.fit(
    trials,
    n_factors=3,  # latent dimensionality
    EMniter=20  # number of iterations
)
```

    vLGP
    Initializing...
    Fitting...
    EM iteration 1
    EM iteration 2
    EM iteration 3
    EM iteration 4
    EM iteration 5
    EM iteration 6
    EM iteration 7
    EM iteration 8
    EM iteration 9
    EM iteration 10
    EM iteration 11
    EM iteration 12
    EM iteration 13
    EM iteration 14
    EM iteration 15
    EM iteration 16
    EM iteration 17
    EM iteration 18
    EM iteration 19
    EM iteration 20
    Inferring...
    Done

```python
trials = fit['trials']  # extract trials
mu = np.stack(trial['mu'] for trial in trials) # extract posterior latent
mu = np.reshape(mu, (-1, dim))
W = np.linalg.lstsq(mu, x.reshape((-1, dim)))[0]
mu = mu @ W
```

```python
# Plot posterior latent
plt.figure(figsize=(20, 5))
plt.plot(x.reshape((-1, dim)) + 2 * np.arange(3), color="b")
plt.plot(mu + 2 * np.arange(3), color="r")
plt.axis("off")
plt.show()
plt.close()
```

```python

```


# 02 - Reverse Time Migration

This notebook is the second in a series of tutorial highlighting various aspects of seismic inversion based on Devito operators. In this second example we aim to highlight the core ideas behind seismic inversion, where we create an image of the subsurface from field recorded data. This tutorial follows on the modelling tutorial and will reuse the modelling operator and velocity model.

## Imaging requirement

Seismic imaging relies on two known parameters:

- **Field data** - or also called **recorded data**. This is a shot record corresponding to the true velocity model. In practice this data is acquired as described in the first tutorial. In order to simplify this tutorial we will generate synthetic field data by modelling it with the **true velocity model**.

- **Background velocity model**. This is a velocity model that has been obtained by processing and inverting the field data. We will look at this methods in the following tutorial as it relies on the method we are describing here. This velocity model is usually a **smooth version** of the true velocity model.

## Imaging computational setup

In this tutorial, we will introduce the back-propagation operator. This operator simulates the adjoint wave-equation, that is a wave-equation solved in a reversed time order. This time reversal led to the naming of the method we present here, called Reverse Time Migration. The notion of adjoint in exploration geophysics is fundamental as most of the wave-equation based imaging and inversion methods rely on adjoint based optimization methods.

## Notes on the operators

As we have already described the creation of a forward modelling operator, we will use a thin wrapper function instead. This wrapper is provided by a utility class called `AcousticWaveSolver`, which provides all the necessary operators for seismic modeling, imaging and inversion. The `AcousticWaveSolver` provides a more concise API for common wave propagation operators and caches the Devito `Operator` objects to avoid unnecessary recompilation. Operators introduced for the first time in this tutorial will be properly described.

As before we initialize printing and import some utilities. We also raise the Devito log level to avoid excessive logging for repeated operator invocations.

```python
import numpy as np
%matplotlib inline

from devito import configuration
configuration['log-level'] = 'WARNING'
```

## Computational considerations

Seismic inversion algorithms are generally very computationally demanding and require a large amount of memory to store the forward wavefield. In order to keep this tutorial as lightweight as possible we are using a very simple
velocity model that requires low temporal and spatial resolution. For a more realistic model, a second set of preset parameters for a reduced version of the 2D Marmousi data set [1] is provided below in comments. This can be run to create some more realistic subsurface images. However, this second preset is more computationally demanding and requires a slightly more powerful workstation.

```python
# Configure model presets
from examples.seismic import demo_model

# Enable model presets here:
preset = 'twolayer-isotropic'  # A simple but cheap model (recommended)
# preset = 'marmousi2d-isotropic'  # A larger more realistic model

# Standard preset with a simple two-layer model
if preset == 'twolayer-isotropic':
    def create_model(grid=None):
        return demo_model('twolayer-isotropic', origin=(0., 0.), shape=(101, 101),
                          spacing=(10., 10.), nbpml=20, grid=grid, ratio=2)
    filter_sigma = (1, 1)
    nshots = 21
    nreceivers = 101
    t0 = 0.
    tn = 1000.  # Simulation last 1 second (1000 ms)
    f0 = 0.010  # Source peak frequency is 10Hz (0.010 kHz)

# A more computationally demanding preset based on the 2D Marmousi model
if preset == 'marmousi2d-isotropic':
    def create_model(grid=None):
        return demo_model('marmousi2d-isotropic', data_path='../../../../opesci-data/',
                          grid=grid, nbpml=20)
    filter_sigma = (6, 6)
    nshots = 301  # Need good covergae in shots, one every two grid points
    nreceivers = 601  # One recevier every grid point
    t0 = 0.
    tn = 3500.  # Simulation last 3.5 second (3500 ms)
    f0 = 0.025  # Source peak frequency is 25Hz (0.025 kHz)
```

# True and smooth velocity models

First, we create the model data for the "true" model from a given demonstration preset. This model represents the subsurface topology for the purposes of this example and we will later use it to generate our synthetic data readings. We also generate a second model and apply a smoothing filter to it, which represents our initial model for the imaging algorithm. The perturbation between these two models can be thought of as the image we are trying to recover.

```python
#NBVAL_IGNORE_OUTPUT
from examples.seismic import plot_velocity, plot_perturbation
from scipy import ndimage

# Create true model from a preset
model = create_model()

# Create initial model and smooth the boundaries
model0 = create_model(grid=model.grid)
model0.vp = ndimage.gaussian_filter(model0.vp.data, sigma=filter_sigma, order=0)

# Plot the true and initial model and the perturbation between them
plot_velocity(model)
plot_velocity(model0)
plot_perturbation(model0, model)
```

## Acquisition geometry

Next we define the positioning and the wave signal of our source, as well as the location of our receivers. To generate the wavelet for our source we require the discretized values of time that we are going to use to model a single "shot",
which again depends on the grid spacing used in our model. For consistency this initial setup will look exactly as in the previous modelling tutorial, although we will vary the position of our source later on during the actual imaging algorithm.

```python
#NBVAL_IGNORE_OUTPUT
# Define acquisition geometry: source
from examples.seismic import AcquisitionGeometry

# First, position source centrally in all dimensions, then set depth
src_coordinates = np.empty((1, 2))
src_coordinates[0, :] = np.array(model.domain_size) * .5
src_coordinates[0, -1] = 20.  # Depth is 20m

# Define acquisition geometry: receivers

# Initialize receivers for synthetic and imaging data
rec_coordinates = np.empty((nreceivers, 2))
rec_coordinates[:, 0] = np.linspace(0, model.domain_size[0], num=nreceivers)
rec_coordinates[:, 1] = 30.

# Geometry

geometry = AcquisitionGeometry(model, rec_coordinates, src_coordinates, t0, tn, f0=.010, src_type='Ricker')
# We can plot the time signature to see the wavelet
geometry.src.show()
```

# True and smooth data

We can now generate the shot record (receiver readings) corresponding to our true and initial models. The difference between these two records will be the basis of the imaging procedure.

For this purpose we will use the same forward modelling operator that was introduced in the previous tutorial, provided by the `AcousticWaveSolver` utility class. This object instantiates a set of pre-defined operators according to an initial definition of the acquisition geometry, consisting of source and receiver symbols. The solver objects caches the individual operators and provides a slightly more high-level API that allows us to invoke the modelling modelling operators from the initial tutorial in a single line. In the following cells we use this to generate shot data by only specifying the respective model symbol `m` to use, and the solver will create and return a new `Receiver` object the represents the readings at the previously defined receiver coordinates.

```python
# Compute synthetic data with forward operator
from examples.seismic.acoustic import AcousticWaveSolver

solver = AcousticWaveSolver(model, geometry, space_order=4)
true_d , _, _ = solver.forward(vp=model.vp)
```

```python
# Compute initial data with forward operator
smooth_d, _, _ = solver.forward(vp=model0.vp)
```

```python
#NBVAL_IGNORE_OUTPUT
# Plot shot record for true and smooth velocity model and the difference
from examples.seismic import plot_shotrecord

plot_shotrecord(true_d.data, model, t0, tn)
plot_shotrecord(smooth_d.data, model, t0, tn)
plot_shotrecord(smooth_d.data - true_d.data, model, t0, tn)
```

# Imaging with back-propagation

As explained in the introduction of this tutorial, this method is based on back-propagation.

## Adjoint wave equation

If we go back to the modelling part, we can rewrite the simulation as a linear system solve:

\begin{equation}
\mathbf{A}(\mathbf{m}) \mathbf{u} = \mathbf{q}
\end{equation}

where $\mathbf{m}$ is the discretized square slowness, $\mathbf{q}$ is the discretized source and $\mathbf{A}(\mathbf{m})$ is the discretized wave-equation. The discretized wave-equation matricial representation is a lower triangular matrix that can be solve with forward substitution. The pointwise writing or the forward substitution leads to the time-stepping stencil.

On a small problem one could form the matrix explicitly and transpose it to obtain the adjoint discrete wave-equation:

\begin{equation}
\mathbf{A}(\mathbf{m})^T \mathbf{v} = \delta \mathbf{d}
\end{equation}

where $\mathbf{v}$ is the discrete **adjoint wavefield** and  $\delta \mathbf{d}$ is the data residual defined as the difference between the field/observed data and the synthetic data $\mathbf{d}_s = \mathbf{P}_r \mathbf{u}$. In our case we derive the discrete adjoint wave-equation from the discrete forward wave-equation to get its stencil.

## Imaging

Wave-equation based imaging relies on one simple concept:

- If the background velocity model is cinematically correct, the forward wavefield $\mathbf{u}$ and the adjoint wavefield $\mathbf{v}$ meet at the reflectors position at zero time offset.

The sum over time of the zero time-offset correlation of these two fields then creates an image of the subsurface. Mathematically this leads to the simple imaging condition:

\begin{equation}
  \text{Image} = \sum_{t=1}^{n_t} \mathbf{u}[t] \mathbf{v}[t]
\end{equation}

In the following tutorials we will describe a more advanced imaging condition that produces shaper and more accurate results.

## Operator

We will now define the imaging operator that computes the adjoint wavefield $\mathbf{v}$ and correlates it with the forward wavefield $\mathbf{u}$. This operator essentially consists of three components:
* Stencil update of the adjoint wavefield `v`
* Injection of the data residual at the adjoint source (forward receiver) location
* Correlation of `u` and `v` to compute the image contribution at each timestep

```python
# Define gradient operator for imaging
from devito import TimeFunction, Operator, Eq, solve
from examples.seismic import PointSource

def ImagingOperator(model, image):
    # Define the wavefield with the size of the model and the time dimension
    v = TimeFunction(name='v', grid=model.grid, time_order=2, space_order=4)

    u = TimeFunction(name='u', grid=model.grid, time_order=2, space_order=4,
                     save=geometry.nt)

    # Define the wave equation, but with a negated damping term
    eqn = model.m * v.dt2 - v.laplace - model.damp * v.dt

    # Use `solve` to rearrange the equation into a stencil expression
    stencil = Eq(v.backward, solve(eqn, v.backward))

    # Define residual injection at the location of the forward receivers
    dt = model.critical_dt
    residual = PointSource(name='residual', grid=model.grid,
                           time_range=geometry.time_axis,
                           coordinates=geometry.rec_positions)
    res_term = residual.inject(field=v, expr=residual * dt**2 / model.m)

    # Correlate u and v for the current time step and add it to the image
    image_update = Eq(image, image - u * v)

    return Operator([stencil] + res_term + [image_update],
                    subs=model.spacing_map)
```

## Implementation of the imaging loop

As just explained, the forward wave-equation is solved forward in time while the adjoint wave-equation is solved in a reversed time order. Therefore, the correlation of these two fields over time requires to store one of the two fields. The computational procedure for imaging follows:

- Simulate the forward wave-equation with the background velocity model to get the synthetic data and save the full wavefield $\mathbf{u}$
- Compute the data residual
- Back-propagate the data residual and compute on the fly the image contribution at each time step.

This procedure is applied to multiple source positions (shots) and summed to obtain the full image of the subsurface. We can first visualize the varying locations of the sources that we will use.

```python
#NBVAL_IGNORE_OUTPUT

# Prepare the varying source locations
source_locations = np.empty((nshots, 2), dtype=np.float32)
source_locations[:, 0] = np.linspace(0., 1000, num=nshots)
source_locations[:, 1] = 30.

plot_velocity(model, source=source_locations)
```

```python
# Run imaging loop over shots
from devito import Function, clear_cache

# Create image symbol and instantiate the previously defined imaging operator
image = Function(name='image', grid=model.grid)
op_imaging = ImagingOperator(model, image)

# Create a wavefield for saving to avoid memory overload
u0 = TimeFunction(name='u', grid=model0.grid, time_order=2, space_order=4,
                  save=geometry.nt)

for i in range(nshots):
    # Important: We force previous wavefields to be destroyed,
    # so that we may reuse the memory.
    clear_cache()

    print('Imaging source %d out of %d' % (i+1, nshots))

    # Update source location
    geometry.src_positions[0, :] = source_locations[i, :]

    # Generate synthetic data from true model
    true_d, _, _ = solver.forward(vp=model.vp)

    # Compute smooth data and full forward wavefield u0
    u0.data.fill(0.)
    smooth_d, _, _ = solver.forward(vp=model0.vp, save=True, u=u0)

    # Compute gradient from the data residual
    v = TimeFunction(name='v', grid=model.grid, time_order=2, space_order=4)
    residual = smooth_d.data - true_d.data
    op_imaging(u=u0, v=v, vp=model0.vp, dt=model0.critical_dt,
               residual=residual)
```

    Imaging source 1 out of 21
    Imaging source 2 out of 21
    Imaging source 3 out of 21
    Imaging source 4 out of 21
    Imaging source 5 out of 21
    Imaging source 6 out of 21
    Imaging source 7 out of 21
    Imaging source 8 out of 21
    Imaging source 9 out of 21
    Imaging source 10 out of 21
    Imaging source 11 out of 21
    Imaging source 12 out of 21
    Imaging source 13 out of 21
    Imaging source 14 out of 21
    Imaging source 15 out of 21
    Imaging source 16 out of 21
    Imaging source 17 out of 21
    Imaging source 18 out of 21
    Imaging source 19 out of 21
    Imaging source 20 out of 21
    Imaging source 21 out of 21

```python
#NBVAL_IGNORE_OUTPUT
from examples.seismic import plot_image

# Plot the inverted image
plot_image(np.diff(image.data, axis=1))
```

```python
assert np.isclose(np.linalg.norm(image.data), 1e6, rtol=1e1)
```

And we have an image of the subsurface with a strong reflector at the original location.

## References

[1] _Versteeg, R.J. & Grau, G. (eds.) (1991): The Marmousi experience. Proc. EAGE workshop on Practical Aspects of Seismic Data Inversion (Copenhagen, 1990), Eur. Assoc. Explor. Geophysicists, Zeist._


```python
%matplotlib inline
```

Band Ratio Measures
===================

Exploring how band ratio measures relate to periodic & aperiodic activity.

Introduction
------------

Band ratios measures are a relatively common measure, proposed to measure oscillatory,
or periodic, activity.

They are typically calculated as:

\begin{align}BR = \frac{avg(low band power)}{avg(high band power)}\end{align}

In this notebook we will explore this measure in the context of conceptualizing
neural power spectra as a combination of aperiodic and periodic activity.

Band Ratios Project
~~~~~~~~~~~~~~~~~~~

This example offers a relatively quick demonstration of how band ratios measures
relate to periodic and aperiodic activity.

We have completed a full project investigating methodological properties of band
ratio measures, which is available
`here <https://github.com/voytekresearch/BandRatios>`_.

```python
# Import numpy and matplotlib
import numpy as np
import matplotlib.pyplot as plt

# Import simulation, utility, and plotting tools
from fooof.bands import Bands
from fooof.utils import trim_spectrum
from fooof.sim.gen import gen_power_spectrum
from fooof.sim.utils import set_random_seed
from fooof.plts.spectra import plot_spectrum_shading, plot_spectra_shading
```

```python
# General Settings

# Define band definitions
bands = Bands({'theta' : [4, 8], 'beta' : [20, 30]})

# Define helper variables for indexing peak data
icf, ipw, ibw = 0, 1, 2

# Plot settings
shade_color = '#0365C0'
```

Simulating Data
~~~~~~~~~~~~~~~

For this example, we will use simulated data. Let's start by simulating a
a baseline power spectrum.

```python
# Simulation Settings
nlv = 0
f_res = 0.1
f_range = [1, 35]

# Define default aperiodic values
ap = [0, 1]

# Define default periodic values for band specific peaks
theta = [6, 0.4, 1]
alpha = [10, 0.5, 0.75]
beta  = [25, 0.3, 1.5]

# Set random seed, for consistency generating simulated data
set_random_seed(21)
```

```python
# Simulate a power spectrum
freqs, powers = gen_power_spectrum(f_range, ap, [theta, alpha, beta], nlv, f_res)
```

Calculating Band Ratios
~~~~~~~~~~~~~~~~~~~~~~~

Band ratio measures are a ratio of power between defined frequency bands.

We can now define a function we can use to calculate band ratio measures, and
apply it to our baseline power spectrum.

For this example, we will be using the theta / beta ratio, which is the
most commonly applied band ratio measure.

Note that it doesn't matter exactly which ratio measure or which frequency band
definitions we use, as the general properties demonstrated here generalize
to different bands and ranges.

```python
def calc_band_ratio(freqs, powers, low_band, high_band):
    """Helper function to calculate band ratio measures."""

    # Extract frequencies within each specified band
    _, low_band = trim_spectrum(freqs, powers, low_band)
    _, high_band = trim_spectrum(freqs, powers, high_band)

    # Calculate average power within each band, and then the ratio between them
    ratio = np.mean(low_band) / np.mean(high_band)

    return ratio
```

```python
# Plot the power spectrum, shading the frequency bands used for the ratio
plot_spectrum_shading(freqs, powers, [bands.theta, bands.beta],
                      color='black', shade_colors=shade_color,
                      log_powers=True, linewidth=3.5)
```

```python
# Calculate a band ratio measure
tbr = calc_band_ratio(freqs, powers, bands.theta, bands.beta)
print('Calculate theta / beta ratio is :\t {:1.2f}'.format(tbr))
```

Periodic Impacts on Band Ratio Measures
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Typical investigations involving band ratios compare differences in band ratio measures
within and between subjects. The typical interpretation of band ratio measures is that
they relate to the relative power between two bands.

Next, lets simulate data that varies across different periodic parameters of the data, and
see how this changes our measured theta / beta ratio, as compared to our baseline
power spectrum.

```python
# Define a helper function for updating parameters
from copy import deepcopy
def upd(data, index, value):
    """Return a updated copy of an array."""
    out = deepcopy(data)
    out[index] = value
    return out
```

```python
# Simulate and collect power spectra with changes in each periodic parameter
spectra = {
    'Theta Frequency' : None,
    'Theta Power' : gen_power_spectrum(\
        f_range, ap, [upd(theta, ipw, 0.5041), alpha, beta], nlv, f_res)[1],
    'Theta Bandwidth' : gen_power_spectrum(\
        f_range, ap, [upd(theta, ibw, 1.61), alpha, beta], nlv, f_res)[1],
    'Alpha Frequency' : gen_power_spectrum(\
        f_range, ap, [theta, upd(alpha, icf, 8.212), beta], nlv, f_res)[1],
    'Alpha Power' : None,
    'Alpha Bandwidth' : gen_power_spectrum(\
        f_range, ap, [theta, upd(alpha, ibw, 1.8845), beta], nlv, f_res)[1],
    'Beta Frequency' : gen_power_spectrum(\
        f_range, ap, [theta, alpha, upd(beta, icf, 19.388)], nlv, f_res)[1],
    'Beta Power' : gen_power_spectrum(\
        f_range, ap, [theta, alpha, upd(beta, ipw, 0.1403)], nlv, f_res)[1],
    'Beta Bandwidth' : gen_power_spectrum(\
        f_range, ap, [theta, alpha, upd(beta, ibw, 0.609)], nlv, f_res)[1],
}
```

```python
# Calculate changes in theta / beta ratios
for label, spectrum in spectra.items():
    if spectrum is not None:
        print('TBR difference from   {:20}   is \t {:1.3f}'.format(\
            label, tbr - calc_band_ratio(freqs, spectrum, bands.theta, bands.beta)))
```

```python
# Create figure of periodic changes
title_settings = {'fontsize': 16, 'fontweight': 'bold'}
fig, axes = plt.subplots(3, 3, figsize=(15, 14))

for ax, (label, spectrum) in zip(axes.flatten(), spectra.items()):

    if spectrum is None: continue

    plot_spectra_shading(freqs, [powers, spectrum],
                         [bands.theta, bands.beta], shade_colors=shade_color,
                         log_freqs=False, log_powers=True, ax=ax)

    ax.set_title(label, **title_settings)
    ax.set_xlim([0, 35]); ax.set_ylim([-1.75, 0])
    ax.xaxis.label.set_visible(False)
    ax.yaxis.label.set_visible(False)

# Turn off empty axes & space out axes
fig.subplots_adjust(hspace=.3, wspace=.3)
_ = [ax.axis('off') for ax in [axes[0, 0], axes[1, 1]]]
```

In the simulations above, we systematically manipulated each parameter of each of the
three different band peaks present in our data. For 7 of the 9 possible changes, we can
do so in a way that creates an identical change in the measured band ratio measure.

Band ratio measures are therefore not specific to band power differences, but rather
can reflect multiple different changes across multiple different periodic parameters.

Aperiodic Impacts on Band Ratio Measures
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Next, we can also examine if changes in aperiodic properties of the data can also
impact band ratio measures. We will explore changes in the aperiodic exponent, with
and without overlying peaks.

To do so, we will use the same approach to simulating, comparing, and plotting
data as above (though note that the code to do so has been condensed in the
next section).

```python
# Simulate and collect power spectra with changes in aperiodic parameters
exp_spectra = {
    'Exponent w Peaks' : \
        [powers,
         gen_power_spectrum(f_range, [0.13, 1.1099],
                            [theta, alpha, beta], nlv, f_res)[1]],
    'Exponent w/out Peaks' : \
        [gen_power_spectrum(f_range, ap, [], nlv, f_res)[1],
         gen_power_spectrum(f_range, [0.13, 1.1417], [], nlv, f_res)[1]]}
```

```python
# Calculate & plot changes in theta / beta ratios
fig, axes = plt.subplots(1, 2, figsize=(15, 6))
fig.subplots_adjust(wspace=.3)
for ax, (label, (comparison, spectrum)) in zip(axes, exp_spectra.items()):
    print('\tTBR difference from   {:20}   is \t {:1.3f}'.format(label, \
        calc_band_ratio(freqs, comparison, bands.theta, bands.beta) - \
        calc_band_ratio(freqs, spectrum, bands.theta, bands.beta)))
    plot_spectra_shading(freqs, [comparison, spectrum],
                         [bands.theta, bands.beta],
                         shade_colors=shade_color,
                         log_freqs=False, log_powers=True, ax=ax)
    ax.set_title(label, **title_settings)
    ax.set_xlim([0, 35]); ax.set_ylim([-1.75, 0])
```

In these simulations, we again see that we can obtain the same measured difference in
band ratio measures from differences in the aperiodic properties of the data. This is
true even if there are no periodic peaks present at all.

This shows that band ratio measures are not even specific to periodic activity,
and can be driven by changes in aperiodic activity.

Conclusion
----------

Band ratio measures are supposed to reflect the relative power of rhythmic neural activity.

However, here we can see that band ratio measures are actually under-determined
in that many different changes of both periodic and aperiodic parameters can affect
band ratio measurements - including aperiodic changes when there is no periodic activity.

For this reason, we conclude that band-ratio measures, by themselves, are
an insufficient measure of neural activity. We propose that approaches such as
parameterizing power spectra are more specific for adjudicating what is changing
in neural data.

For more investigation into band ratios, their methodological issues, applications to real
data, and a comparison to parameterizing power spectra, see the full project
`here <https://github.com/voytekresearch/BandRatios>`_,


# Practical 0.5: Machine Learning Basics

## Introduction
In this practical, we introduce the idea of classification (sorting things into categories) using a machine-learning model. We explore the relationship between a classifier's parameters and the decision boundary (a line that separates categories) and also introduce the idea of a loss function. Finally, we briefly introduce Tensorflow.

## Learning Objectives
* Understand the idea of **classification**
* Understand the concept of (linear) **separability** of a dataset.
* Understand what the **parameters** of a classifier are and how they relate to the **decision boundary**
* Be able to briefly explain what **Tensorflow** is.

```
#@title [RUN ME!] Imports { display-mode: "form" }
!pip install -q moviepy
!pip install -q imageio
from __future__ import absolute_import, division, print_function

import tensorflow as tf
import numpy as np                 # Numpy is an efficient linear algebra library.
import matplotlib.pyplot as plt    # Matplotlib is used to generate plots of data.
from matplotlib import animation, rc

from IPython import display

try:
  tf.enable_eager_execution()
  print('Running eagerly')
except ValueError:
  print('Already running eagerly')

tfe = tf.contrib.eager
```

    Imageio: 'ffmpeg-linux64-v3.3.1' was not found on your computer; downloading it now.
    Try 1. Download from https://github.com/imageio/imageio-binaries/raw/master/ffmpeg/ffmpeg-linux64-v3.3.1 (43.8 MB)
    Downloading: 8192/45929032 bytes (0.0%)630784/45929032 bytes (1.4%)1761280/45929032 bytes (3.8%)3309568/45929032 bytes (7.2%)5693440/45929032 bytes (12.4%)9068544/45929032 bytes (19.7%)13049856/45929032 bytes (28.4%)17113088/45929032 bytes (37.3%)21045248/45929032 bytes (45.8%)24961024/45929032 bytes (54.3%)28975104/45929032 bytes (63.1%)33030144/45929032 bytes (71.9%)37052416/45929032 bytes (80.7%)41066496/45929032 bytes (89.4%)45080576/45929032 bytes (98.2%)45929032/45929032 bytes (100.0%)
      Done
    File saved as /root/.imageio/ffmpeg/ffmpeg-linux64-v3.3.1.
    Running eagerly

## Outline
In this practical, we tackle the task of **classification** of a simple, synthetic dataset. Classification in machine learning involves learning a labelling of examples into one (or more) discrete categories. This differs from another common task in machine learning called **regression**, which involves learning a mapping from inputs to a continuous-valued output.

1. We begin by introducing a synthetic dataset of red and blue points which we want to separate
2. We introduce and explore the idea of **linear seperability**
3. We define a **loss** as a measure of how good of a seperator a particular line is
4. We briefly introduce TensorFlow and show how it can be used to automatically find the minimum of a loss function.

```
#@title [RUN ME!] Helper functions { display-mode: "form" }
def plot_dataset(inputs, labels):
  # Plot the given 2D inputs and labels using Matplotlib.
  plt.scatter(
      inputs[:, 0], inputs[:, 1],
      c=['red' if label > 0 else 'blue' for label in labels])

  plt.axis('equal')

  plt.xlabel('x1')
  plt.ylabel('x2')
```

## The Data

Run the code in the cell below, and look at the resulting plot. It should produce a simple 2-D data set consisting of 2 classes of points, the classes are represented by colours blue and red. Our task is to build a **binary classifier** that can distinguish between red and blue points (red and blue are referred to as the **classes** of the points), using only the 2-D coordinates of a point. In other words, we want a function that takes as input a 2-D vector representing the coordinates of a point and returns a value of 1 or 0 indicating whether the point is red or blue. Here we have **encoded** the colours red and blue into the numbers 1 and 0 (which make it easier to work with in maths and code!)

Note: we have arbitrarily encoded red as 1 and blue as 0, you could do it the other way around too as long as you're consistent!

```
#@title Generate the Dataset  {run: "auto"}
# Define the centre(s) of the points
centre = 1    #@param {type:"slider", min:0, max:2, step:0.1}

points_in_class = 20  # How many points we want per class

# A fixed random seed is a common "trick" used in ML that allows us to recreate
# the same data when there is a random element involved.
np.random.seed(0)

# Generate random points in the "red" class
red_inputs = np.random.normal(loc=centre, scale=1.0, size=[points_in_class, 2])
# Generate random points in the "blue" class
blue_inputs = np.random.normal(loc=-centre, scale=1.0, size=[points_in_class, 2])
# Put these together
inputs = np.concatenate((red_inputs, blue_inputs), axis=0)

# The class (label) is 1 for red or 0 for blue
red_labels = np.ones(points_in_class)
blue_labels = np.zeros(points_in_class)
labels = np.concatenate((red_labels, blue_labels), axis=0)

# num_data_points is the total data set size
num_data_points = 2 * points_in_class

plot_dataset(inputs, labels)
```

###What does the data look like?
The inputs are 2-dimensional vectors (points in a 2-D space). Here are the coordinates of 4 points, which we've deliberately chosen so that points 1 and 2 are "red" and points 3 and 4 are "blue".

```
print('Input 1:\t', inputs[0])
print('Input 2:\t', inputs[1])

print('Input 3:\t', inputs[-1])
print('Input 4:\t', inputs[-2])
```

The labels are either 0 or 1. Here are the labels corresponding to the points above:

```
print('Label 1:\t', labels[0])
print('Label 2:\t', labels[1])

print('Label 3:\t', labels[-1])
print('Label 4:\t', labels[-2])
```

### Aside: Other Examples of Binary Classification Problems

In this practical, we are using a synthetic dataset where we have 2 classes of 2-D points that come from different distributions, distinguised by the colours, red and blue. To make this more concrete, here are some examples of more real-world binary classification problems.

* Determine whether an email message (input) is SPAM or NOT SPAM (label)
* Determine whether an image, represented by its encoded pixel values (input) is a picture of a DOG or a CAT (label)
* Determine whether energy usage of a building will go UP or DOWN (label) next month, using a time series of past energy usage values (input)

## Linear separability

Linear separability of a D-dimensional dataset with 2 classes means that there exists a single (D-1)-dimensional (hyper-)plane that separates the classes (a hyperplane is a generalisation of a straight line to many dimensions). In this case, the dataset is 2-dimensional and is **linearly separable** if it is possible to draw a (1-D) line between the red and blue points such that all of the red points lie on one side of the line and all of the blue points on the other.

### Exploratory Task
In the code cell under the heading "The Data", change the slider for the ```centre``` value. This will automatically update the value in the code and will redraw the plot.

* At what value of centre does the dataset become linearly separable?

### Question for discussion
Can you think of some 2-D, 2-class datasets, similar to the one above, that are separable (the points from the 2 classes don't overlap each other), but are not **linearly** separable? Draw some examples on paper or plot them using Matplotlib and discuss this with your neighbour and tutors.

## Drawing the line

As you may recall from school, a line in 2 dimensions, with coordinate axes $x_1$ and $x_2$, which passes through the origin (0, 0) can be represented by the equation $w_1x_1 + w_2x_2 = 0$

We can also write this in vector form as: $\mathbf{w}^T\mathbf{x} = 0$, where $\mathbf{w}^T = [w_1, w_2]$ and $\mathbf{x}^T = [x_1, x_2]$.

When a line (or hyperplane) is defined this way, we call the **parameters**, $\mathbf{w} = (w_1, w_2)$ a **normal vector** for the line. The normal vector is orthogonal (perpendicular) to the line. We want to construct such a line that separates red and blue  points, which we will call a **decision boundary**.

In the following cell, we plot our dataset along with a normal vector $\mathbf{w}$ and decision boundary. You can adjust the values of $w_1$ and $w_2$ by using the sliders on the right. Observe the effect that the values have on the normal vector drawn in *red* and decision boundary in *black*. Adjust the values so that the black line separates the blue and red points (i.e. red points on one side and blue on the other). Your line should also have the normal vector pointing in the direction of the red points. The reason that direction is significant is that we want to eventually **classify** points on one side of the line as being red and the other as being blue.

Is it possible to find a line through the origin that perfectly separates the points?

**Note**: Each of our inputs is a 2-D vector, made up of two coordinate values. We refer to these 2 coordinate axes as $x_1$ and $x_2$. For example, if we have an input $(1, 2)$, then we would say $x_1 = 1$ and $x_2 = 2$ for that point.

```
#@title Effect of parameters {run: "auto"}

# Define the parameters
w1 = -1 #@param { type: "slider", min: -5, max: 5, step: 0.1 }
w2 = 1 #@param { type: "slider", min: -5, max: 5, step: 0.1 }

plot_dataset(inputs, labels)

# Add the weight vector to the plot. We plot it in red, as it has to "point"
# in the direction of the red points.
ax = plt.axes()
ax.arrow(0, 0, w1, w2, head_width=0.3, head_length=0.3, fc='r', ec='r')

# Plot part of the decision boundary in black. It is orthogonal to the weight
# vector.
t = 2
plt.plot([-t * w2, t * w2], [t * w1, -t * w1], 'k-')

plt.xlim([-4, 4])
plt.ylim([-4, 4])

plt.show()
```

## Classification
Given a normal vector $\mathbf{w}$, we can evaluate which side of the decision boundary a particular point $\mathbf{x_i} = (x_{i,1}, x_{i, 2})$ lies by evaluating $\mathbf{w^Tx_i}$. If $\mathbf{w^Tx_i} > 0$, the point $\mathbf{x_i}$ lies to one side of the boundary (in the direction of the normal vector), and we can classify that point as belonging to class 1 (in our case, "red"). If $\mathbf{w^Tx_i} < 0$ the point lies on the other side and can be classified as class 0 (in our case, "blue"). Finally if $\mathbf{w^Tx_i} = 0$ the point lies on the decision boundary and we can decide whether to classify it as either 0 or 1, or ignore it.

## How "good" is the line?

If you've played around with the above code, you may have developed some intuition around how different settings of the parameters influence the final placement of the decision boundary. The purpose of machine learning is to *automatically* adjust the values of $w_1$ and $w_2$ to find a suitable decision boundary! But to do this, we need to mathematically specify some **loss** or **objective** function. The loss is a function of the parameters $w_1$ and $w_2$ and tells us how good a certain configuration of the parameter values are at classifying the data. This function is defined such that it reaches its optimum setting when it is minimised, i.e. the *smaller* its value, the *better* the separation between the classes. An additional property a loss function can have that is often crucial for machine learning is being *differentiable*. A differentiable loss function allows us to use *gradient-based optimisation* to find its minimum and the corresponding optimal values of $w_1$ and $w_2$.

For this classification problem, we consider the **binary cross-entropy** loss function to measure how good the model's predictions are.  This loss function compares the model's prediction for each example, $\mathbf{x_i}$ to the true **target** $y_i$ (we often refer to the true label associated with an input as the "target"). It then applies the non-linear log function to penalise the model for being further from the true class. The equation for the binary cross entropy loss, on a dataset with $N$ points is:

\begin{align}
l(\mathbf{w}) = -\frac{1}{N}\sum_{i=1}^N y_i log(\hat{y}_i) + (1-y_i)log(1-\hat{y}_i)
\end{align}

where $\hat{y}_i = \operatorname{sigmoid}(\mathbf{w}^T\mathbf{x_i})$ and the $\operatorname{sigmoid}$ function is defined as:

$$
\mathrm{sigmoid}(a) = \frac{1}{1 + e^{-a}} .
$$

The reason we use the $\operatorname{sigmoid}$ function is because our classifier can output any real value. The binary cross entropy loss function, however, expects the predictions made by a classifier to be between $0$ and $1$. The sigmoid function "squashes" any real number inputs to lie in the interval $(0, 1)$.

Let's now wrap this in a Python function so that we can compute the loss for any values of $w_1$ and $w_2$:

```
def compute_loss(w1, w2):

  total_log_likelihood = 0

  # Add the contribution of each datapoint to the loss
  for (x1, x2), target in zip(inputs, labels):
    # As our targets are 0 or 1, our prediction function must output a value between 0 and 1.
    # The sigmoid function 'squashes' any value to lie between 0 and 1:
    prediction = tf.sigmoid(w1*x1 + w2*x2)

    # Compute the local loss term
    # We add 1e-10 to make the log operations numerically stable (i.e. avoid taking the log of 0.)
    log_likelihood = target * tf.log(prediction + 1e-10) + (1.-target)*tf.log(1.-prediction + 1e-10)
    total_log_likelihood += log_likelihood

  loss = -total_log_likelihood
  average_loss = loss / len(inputs)
  return average_loss
```

### More on the sigmoid function

The sigmoid function is defined as
$$
\mathrm{sigmoid}(a) = \frac{1}{1 + e^{-a}} .
$$
Can you show that
$$
1 - \mathrm{sigmoid}(a) = \frac{1}{1 + e^{a}} ,
$$
and draw both of these on a sheet of paper?

* What is its value when $a = \mathbf{w}^{T}\mathbf{x}$ is positive? negative? and zero?
* What happends to its value when  $a = \mathbf{w}^{T}\mathbf{x}$ becomes larger?
* What is the value of $\mathrm{sigmoid}(\mathbf{w^Tx})$ when $\mathbf{w}^T\mathbf{x} = 0$? How does this change how we classify points on either side of the decision boundary?

After working through the above questions, explain to your neighbour why the binary cross-entropy loss function makes sense.

**HINT**: Remember the idea of the loss function is to return small values when the classifier makes good predictions and large values when the classifier makes bad predictions.

### Bonus Question
We derived the `compute_loss()` function above based on minimising the log-loss of the prediction error. This is related to a concept called 'cross-entropy'. But another way of deriving exactly the same loss function is by maximising the likelihood of the data under the model $P(y | x, w_1, w_2)$. If you are familiar with this concept (eg. from statistics), see if you can derive it this way as well.

### Optional Further Reading
More information on the [cross-entropy loss](http://ml-cheatsheet.readthedocs.io/en/latest/loss_functions.html) and another interesting connection to [information theory](https://rdipietro.github.io/friendly-intro-to-cross-entropy-loss/).

## Loss value for your chosen $w_1$ and $w_2$

The following line of code computes the loss value for your chosen values of $w_1$ and $w_2$. Try changing the values of $w_1$ and $w_2$ using the sliders above and rerun the line below. Can you see how a better separation results in a lower loss?

Note: If you've used TensorFlow before, it might be confusing how this code cell works! We explain more about this later...

```
compute_loss(w1, w2).numpy()
```

    0.09962160420831814

## Visualising the loss function

We can visualise the loss function for our dataset by plotting its value at every point in a whole grid of $w_1$ and $w_2$ parameter values. We do this using a **contour plot**, which is a technique for visualising a 3-D function on a 2-D plot by letting colour represent the third dimension. All of the points with the same colour have the same loss value.

```
# We define a function so we can re-use this code later
def plot_contours():
  # Generate a whole bunch of (w1, w2) points in a grid
  ind = np.linspace(-5, 5, 50)
  w1grid, w2grid = np.meshgrid(ind, ind)

  # Compute the loss for each point in the grid
  losses = []
  for w1s, w2s in zip(w1grid, w2grid):
    loss = compute_loss(w1s, w2s)
    losses.append(loss)

  # Pack the loss values for every value of w1 & w2 into one (50,50) array
  losses_array = np.concatenate(losses).reshape(50,50)

  # Now plot the resulting loss function as a contour plot over the whole grid of (w1, w2) values.
  fig = plt.figure()
  plt.contourf(w1grid, w2grid, losses_array, 20, cmap=plt.cm.jet)
  cbar = plt.colorbar()
  cbar.ax.set_ylabel('Binary cross-entropy loss value')
  plt.xlabel('w1 value')
  plt.ylabel('w2 value')
  plt.title('Total loss for different values of w1 and w2')

plot_contours()
```

## Optimising the loss using TensorFlow

Now that we have a function that gives us the loss for different values of $w_1$ and $w_2$, we want an automated method to find the values that minimise the loss function. This is where optimisation by **gradient descent** comes in. The idea is that for each (batch of) data points, we compute the loss using the current values of $w_1$ and $w_2$ on the data. We then compute the **gradient** (or derivative) of the loss function at the current values of $w_1$ and $w_2$. The negative of the gradient points in the direction of *steepest descent* along the loss function. By adjusting the values of $w_1$ and $w_2$ in the direction of the negative gradient, we move closer towards the minimum of the loss function (provided the loss function is "well behaved"). How big of a step we take is mediated by the **learning rate**. To do this more easily, we will use TensorFlow.

Don't worry if you don't understand what's going on here, you will see this in a lot more detail during the **Mathematics for Machine Learning** lectures!

### Aside: TensorFlow

TensorFlow (TF) is an open source software library for numerical computation using the concept of Tensors. You can think of Tensors as being a generalisation of matrices to higher dimensions, or roughly equivalent to multi-dimensional arrays. Scalars are 0-dimensional tensors, vectors are 1-dimensional, standard matrices are 2-dimensional, and higher-dimensional tensors have 3 or more dimensions. You can think of dimensions as representing groups of numbers that mean the same thing. For example, for images, we often use 3-dimensional tensors where the first dimension represents the red, green, and blue color channels of the image, and the next two are the columns and rows of pixels of the image.

**Note**: Don't be confused when people say "2-D vector" or "3-D vector", which refers to a 1-dimensional tensor that has size 2 or 3.

The major advantage of using TensorFlow is that it can automatically derive the gradients of many mathematical expressions involving tensors. It achieves this through a process called "automatic differentiation". Tensorflow also supports multiple "kernels", allowing you to easily run your code on normal processors (CPUs), graphics cards (GPUs) and other more exotic hardware accelerators like Google's Tensor Processing Units (TPUs)

Tensorflow actually provides **two modes of operation**, the first, called "graph mode", builds a computation graph upfront and then feeds data into the graph. By building the graph upfront, Tensorflow can apply optimisations to the graph that allow it to extract peak performance from the hardware you're running on. You will have encountered this mode if you used Tensorflow before or attended the Indaba last year! The second mode, called ["Eager-mode"](https://www.tensorflow.org/guide/eager), is a lot newer and evaluates Tensor operations imperatively (in the order you write them), similar to NumPy and PyTorch. Eager-mode is slightly less performant but a lot more intuitive, especially if you've never used a "define-and-run" programming style (like graph mode) before, and is therefore the mode we will use in these practicals.

### Using Tensorflow to optimise the loss
We use TensorFlow to optimise the parameters of the model with gradient descent. We loop over the dataset multiple times (called "epochs") and plot the final decision boundary along with a plot showing how the parameters and loss changed over the epochs.

**Note**: TensorFlow is probably overkill for this example, becaue the gradient is very easy to calculate, but we introduce it here because it will become essential to calculate the gradients of more complex models in later practicals!

```
lr = 0.25  # The learning rate

# Initialise Tensorflow variables representing our parameters.
# We need to use TensorFlow variables here rather than Numpy or Python ones so
# that TensorFlow is able to compute gradients.
w1 = tfe.Variable(-2.0)
w2 = tfe.Variable(-4.0)

plot_contours()

# Loop over the dataset multiple times
parameter_values = []
for epoch in range(20):
  plt.scatter(w1.numpy(), w2.numpy(), marker='o', color='black')

  with tf.GradientTape() as tape:
    loss = compute_loss(w1, w2)

  # Now we take a step in parameter space in the direction of the gradient to move the parameters closer (hopefully!) to their optimum
  dw1, dw2 = tape.gradient(loss, [w1, w2])

  # Step 'lr units' in the direction of the negative gradient
  # We achieve this by subtracting lr * dw1 and lr * dw2 from the w1 and w2 variables
  w1.assign_sub(lr*dw1)
  w2.assign_sub(lr*dw2)

print('Finished optimisation, the final values of w1 and w2 are:')
print(w1.numpy(), w2.numpy())

# Plot the final point on the loss surface.
plt.scatter(w1.numpy(), w2.numpy(), marker='x', color='red')
plt.show()

# Plot the final decision boundary
plot_dataset(inputs, labels)
ax = plt.axes()
ax.arrow(0, 0, w1.numpy(), w2.numpy(), head_width=0.3, head_length=0.3, fc='r', ec='r')
plt.plot([-2 * w2.numpy(), 2 * w2.numpy()], [2 * w1.numpy(), -2 * w1.numpy()], 'k-')

plt.xlim([-4, 4])
plt.ylim([-4, 4])

plt.show()
```

How did the final values of $w_1$ and $w_2$ found by Tensorflow correspond to the ones you found manually?

## Optional Tasks
If you've worked through this practical, answered all the questions and feel you have a good understanding of what's going on, try the following tasks:

1. Add a **bias** parameter to the equation for the decision boundary and visualise how that changes the decision boundary, the loss and the ultimate solution found by Tensorflow.
2. Add a **regulariser**, for example, the L2 regulariser (see the appendix below for more information) - how does it affect the contour plot of the parameters vs the loss? How does changing the strength of regularisation affect the loss?

Note: The benefit of using regularisation will be discussed in the next practical!

# Next Steps
Have a look at [last year's practical](https://github.com/deep-learning-indaba/practicals2017/blob/master/practical1.ipynb) which takes a more "bottom-up" approach, covers more detail on how gradients are computed and also looks at a multi-class classification problem with a non-linear decision boundary.

Note: last year's practicals use Tensorflow's "graph mode" as opposed to "Eager mode" that we use here.

# Appendix

### L1 and L2 Regularisation

Two of the most simple regularization methods are L1 and L2 regularization (you may have heard of them as _Lasso_ and _Ridge_ regression if you've used linear regression before). Both of these methods regularize the model by adding a term to the loss that penalizes the model if it becomes too complex.
L1 regularization adds a term based on the L1 norm:

$loss_{L1} = loss + \lambda \sum_i |w_i|$

where $\lambda$ is a parameter that controls the amount of regularization, and $w_i$ are the parameters of the model. L1 regularization has the effect of forcing some parameters to shrink to 0, effectively removing them from the model.

L2 regularization similarly adds a term based on the L2 norm:

$loss_{L2} = loss + \lambda \sum_i w_i^2$.

L2 regularization has the effect of preventing any of the parameters from becoming too large and _overpowering_ the others.

In some cases it can work well to use both L1 and L2 regularization.

For more information see the articles [here](http://enhancedatascience.com/2017/07/04/machine-learning-explained-regularization/) and [here](https://towardsdatascience.com/l1-and-l2-regularization-methods-ce25e7fc831c).


# Examples of Hamiltonian Conversion
This page contains several examples of converting the hamiltonian of specific molecules to the Jordan-Wigner form and the Bravyi-Kitaev form

## Some Background & Terminologies
### Hamiltonian in the Second Quantization Form
In order to prepare the hamiltonian for VQE, we will first express the molecular hamiltonian in the second quantization form:
$$
\begin{align}
H = \sum_{ij}h_{ij}a^\dagger_{i}a_{j} + \sum_{ijkl}h_{ijkl}a^\dagger_{i}a^\dagger_{j}a_{k}a_{l}
\end{align}
$$
The first summation is the one-electron integral, which includes the kinetic potential and the coulumb interaction (electron-nuclear). Its coefficient $h_{ij}$ can be expressed as:
$$
\begin{align}
h_{ij} = \int<\psi_{i}(x)|-\frac{1}{2}\nabla^{2}-\sum_{\alpha}\frac{Z_{\alpha}}{r_{\alpha,x}}|\psi_{j}(x)>dx
\end{align}
$$
The second summation is the two-electron integral, which includes all the electron-electron interaction. Its coefficient $h_{ijkl}$ can be expressed as:
$$
\begin{align}
h_{ijkl} = \int<\psi_{i}(x_{1})|<\psi_{j}(x_{2})|\psi_{k}(x_{2})>|\psi_{l}(x_{1})>dx_{1}dx_{2}
\end{align}
$$
Calculating the above coefficients numerically or analytically can be very costly. We will use OpenFermion to obtain them for simplicity.

### Fermionic Operator
OpenFermion express the creation operator (i.e. $a^\dagger_{i}$) and the annihilation operator (i.e. $a_{i}$) using the FermionOperator data structure. The following are examples of valid FermionOperators:
$$
\begin{align}
& a_1 \nonumber \\
& 1.7 a^\dagger_3 \nonumber \\
&-1.7 \, a^\dagger_3 a_1 \nonumber \\
&(1 + 2i) \, a^\dagger_4 a^\dagger_3 a_9 a_1 \nonumber \\
&(1 + 2i) \, a^\dagger_4 a^\dagger_3 a_9 a_1 - 1.7 \, a^\dagger_3 a_1 \nonumber
\end{align}
$$

Each fermionic operator is represented in a 2-tuple structure as:
$$
\begin{align}
I & \mapsto () \nonumber \\
a_1 & \mapsto ((1, 0),) \nonumber \\
a^\dagger_3 & \mapsto ((3, 1),) \nonumber \\
a^\dagger_3 a_1 & \mapsto ((3, 1), (1, 0)) \nonumber \\
a^\dagger_4 a^\dagger_3 a_9 a_1 & \mapsto ((4, 1), (3, 1), (9, 0), (1, 0)) \nonumber
\end{align}
$$

### Jordan-Wigner and Bravyi-Kitaev
After obtaining the total hamiltonian, the next step is to convert it to some form that can be understood by the quantum computer. Electron are fermions with half integer spin and follows Fermi-Dirac distribution. On the other hand, qubit hamiltonian are bosonic with integer spin and follows Bose-Einstein distribution. OpenFermion currently allows two methods to transform the original fermionic hamiltonian to bosonic hamiltonian. The are called the Jordan-Wigner tranformation and Bravyi-Kitaev transformation. We will investigate more into this in the future, with the aim of obtaining more efficient mapping results. MORE INFORMATION NEEDED HERE

### Define the function to obtain the transformation
This is the same for different molecules. You must run this block before running the examples below

```python
import openfermion
from openfermion.hamiltonians import MolecularData
from openfermion.ops import FermionOperator
from openfermion.ops import InteractionOperator
from openfermion.transforms import get_fermion_operator, get_interaction_operator, jordan_wigner, bravyi_kitaev, bravyi_kitaev_fast

def get_transformation(hamiltonian):
    # get the fermionic hamiltonian (note: this is required for BK and BKSF)
    fermionic_hamiltonian = get_fermion_operator(hamiltonian)
    # print("the fermionic hamiltonian is:")
    # print(fermionic_hamiltonian)

    # get the interaction hamiltonian (note: this is required for BKSF)
    interaction_hamiltonian = get_interaction_operator(fermionic_hamiltonian)
    # print("the interactive hamiltonian is:")
    # print(interactive_hamiltonian)

    # Get the JW form
    jw_operator = jordan_wigner(hamiltonian)
    print("the Jordan-Wigner form is:")
    print(jw_operator)
    print()

    # Get the BK form
    bk_operator = bravyi_kitaev(fermionic_hamiltonian)
    print("the Bravyi-Kitaev form is:")
    print(bk_operator)
    print()

    # Get the BKSF form
    bksf_operator = bravyi_kitaev_fast(interaction_hamiltonian)
    print("the Bravyi-Kitaev-Super-Fast form is:")
    print(bksf_operator)
    print()
```

## Example 1: Hydrogen Molecule ($H_{2}$)

```python
import openfermion
from openfermion.hamiltonians import MolecularData
from openfermion.ops import FermionOperator
from openfermion.ops import InteractionOperator
from openfermion.transforms import get_fermion_operator, get_interaction_operator, jordan_wigner, bravyi_kitaev, bravyi_kitaev_fast

# set the molecular structure
diatomic_bond_length = .7414
geometry = [('H', (0., 0., 0.)),
            ('H', (0., 0., diatomic_bond_length))]
basis = 'sto-3g'  # take the basis as sto-3g, a popular Guassian basis
multiplicity = 1
charge = 0
description = format(diatomic_bond_length)
molecule = openfermion.MolecularData(geometry, basis, multiplicity, description=description)
molecule.load()

# get the total hamiltonian
hamiltonian = molecule.get_molecular_hamiltonian()
# print("the hamiltonian in the second-quantization form is:")
# print(hamiltonian)

# get the JW, BK, BKSF forms
# Note: you must run the previous block code before
get_transformation(hamiltonian)
```

    the Jordan-Wigner form is:
    -0.09886397351781592 [] +
    -0.04532220209856541 [X0 X1 Y2 Y3] +
    0.04532220209856541 [X0 Y1 Y2 X3] +
    0.04532220209856541 [Y0 X1 X2 Y3] +
    -0.04532220209856541 [Y0 Y1 X2 X3] +
    0.17119774853325856 [Z0] +
    0.16862219143347554 [Z0 Z1] +
    0.12054482186554413 [Z0 Z2] +
    0.16586702396410954 [Z0 Z3] +
    0.17119774853325856 [Z1] +
    0.16586702396410954 [Z1 Z2] +
    0.12054482186554413 [Z1 Z3] +
    -0.22278592890107016 [Z2] +
    0.17434844170557132 [Z2 Z3] +
    -0.22278592890107016 [Z3]

    the Bravyi-Kitaev form is:
    (-0.09886397351781583+0j) [] +
    (0.04532220209856541+0j) [X0 Z1 X2] +
    (0.04532220209856541+0j) [X0 Z1 X2 Z3] +
    (0.04532220209856541+0j) [Y0 Z1 Y2] +
    (0.04532220209856541+0j) [Y0 Z1 Y2 Z3] +
    (0.17119774853325848+0j) [Z0] +
    (0.1711977485332586+0j) [Z0 Z1] +
    (0.16586702396410954+0j) [Z0 Z1 Z2] +
    (0.16586702396410954+0j) [Z0 Z1 Z2 Z3] +
    (0.12054482186554413+0j) [Z0 Z2] +
    (0.12054482186554413+0j) [Z0 Z2 Z3] +
    (0.16862219143347554+0j) [Z1] +
    (-0.22278592890107013+0j) [Z1 Z2 Z3] +
    (0.17434844170557132+0j) [Z1 Z3] +
    (-0.22278592890107018+0j) [Z2]

    the Bravyi-Kitaev-Super-Fast form is:
    (-0.09886397351781614+0j) [] +
    (0.045322202098565426+0j) [X0 X3] +
    (0.045322202098565426+0j) [Y0 Z1 Z2 Y3] +
    (0.045322202098565426+0j) [Y0 Y3] +
    (-0.0453222020985654+0j) [Z0 X1 X2 Z3] +
    (-0.0453222020985654+0j) [Z0 Y1 Y2 Z3] +
    (0.17119774853325856+0j) [Z0 Z1] +
    (0.24108964373108827+0j) [Z0 Z1 Z2 Z3] +
    (0.17119774853325856+0j) [Z0 Z2] +
    (0.3317340479282191+0j) [Z0 Z3] +
    (-0.0453222020985654+0j) [Y1 Y2] +
    (0.34297063313904685+0j) [Z1 Z2] +
    (-0.22278592890107018+0j) [Z1 Z3] +
    (-0.22278592890107016+0j) [Z2 Z3]

## Example 2: Lithium hydride (LiH)

```python
import openfermion
from openfermion.hamiltonians import MolecularData
from openfermion.ops import FermionOperator
from openfermion.ops import InteractionOperator
from openfermion.transforms import get_fermion_operator, get_interaction_operator, jordan_wigner, bravyi_kitaev, bravyi_kitaev_fast
# set the molecular structure
diatomic_bond_length = 1.45
geometry = [('Li', (0., 0., 0.)), ('H', (0., 0., diatomic_bond_length))]
basis = 'sto-3g'
multiplicity = 1

# Set Hamiltonian parameters. This will limit the number of valence electron and electronic orbitals considered.
# You can play with it to obtain different total hamiltonians and their corresponding JW/BK forms
active_space_start = 1
active_space_stop = 3

# Get the Hamiltonian.
molecule = MolecularData(geometry, basis, multiplicity, description="1.45")
molecule.load()
hamiltonian = molecule.get_molecular_hamiltonian(
    occupied_indices=range(active_space_start),
    active_indices=range(active_space_start, active_space_stop))

# get the JW, BK, BKSF forms
# Note: you must run the previous block code before
get_transformation(hamiltonian)
```

    the Jordan-Wigner form is:
    -7.498946902010707 [] +
    -0.0029329964409502253 [X0 X1 Y2 Y3] +
    0.0029329964409502253 [X0 Y1 Y2 X3] +
    0.01291078027311749 [X0 Z1 X2] +
    -0.0013743761078958685 [X0 Z1 X2 Z3] +
    0.011536413200774974 [X0 X2] +
    0.0029329964409502253 [Y0 X1 X2 Y3] +
    -0.0029329964409502253 [Y0 Y1 X2 X3] +
    0.01291078027311749 [Y0 Z1 Y2] +
    -0.0013743761078958685 [Y0 Z1 Y2 Z3] +
    0.011536413200774974 [Y0 Y2] +
    0.1619947538800418 [Z0] +
    0.011536413200774974 [Z0 X1 Z2 X3] +
    0.011536413200774974 [Z0 Y1 Z2 Y3] +
    0.12444770133137588 [Z0 Z1] +
    0.054130445793298836 [Z0 Z2] +
    0.05706344223424907 [Z0 Z3] +
    0.01291078027311749 [X1 Z2 X3] +
    -0.0013743761078958685 [X1 X3] +
    0.01291078027311749 [Y1 Z2 Y3] +
    -0.0013743761078958685 [Y1 Y3] +
    0.1619947538800418 [Z1] +
    0.05706344223424907 [Z1 Z2] +
    0.054130445793298836 [Z1 Z3] +
    -0.013243698330265952 [Z2] +
    0.08479609543670981 [Z2 Z3] +
    -0.013243698330265966 [Z3]

    the Bravyi-Kitaev form is:
    (-7.49894690201071+0j) [] +
    (0.011536413200774975+0j) [X0 X1 X2] +
    (0.0013743761078958677+0j) [X0 X1 X2 Z3] +
    (0.01291078027311749+0j) [X0 Y1 Y2] +
    (0.0029329964409502266+0j) [X0 Z1 X2] +
    (0.0029329964409502266+0j) [X0 Z1 X2 Z3] +
    (0.011536413200774975+0j) [Y0 X1 Y2] +
    (0.0013743761078958677+0j) [Y0 X1 Y2 Z3] +
    (-0.01291078027311749+0j) [Y0 Y1 X2] +
    (0.0029329964409502266+0j) [Y0 Z1 Y2] +
    (0.0029329964409502266+0j) [Y0 Z1 Y2 Z3] +
    (0.16199475388004184+0j) [Z0] +
    (0.011536413200774975+0j) [Z0 X1 Z2] +
    (0.0013743761078958677+0j) [Z0 X1 Z2 Z3] +
    (-0.012910780273117487+0j) [Z0 X1 Z3] +
    (0.16199475388004186+0j) [Z0 Z1] +
    (0.05706344223424907+0j) [Z0 Z1 Z2] +
    (0.05706344223424907+0j) [Z0 Z1 Z2 Z3] +
    (0.054130445793298836+0j) [Z0 Z2] +
    (0.054130445793298836+0j) [Z0 Z2 Z3] +
    (-0.0013743761078958677+0j) [X1] +
    (0.012910780273117487+0j) [X1 Z2] +
    (-0.011536413200774975+0j) [X1 Z3] +
    (0.12444770133137588+0j) [Z1] +
    (-0.013243698330265952+0j) [Z1 Z2 Z3] +
    (0.08479609543670981+0j) [Z1 Z3] +
    (-0.013243698330265966+0j) [Z2]

    the Bravyi-Kitaev-Super-Fast form is:
    (-7.498946902010707+0j) [] +
    (0.002932996440950222+0j) [X0 X5] +
    (0.002932996440950222+0j) [Y0 Z1 Z4 Y5] +
    (0.002932996440950222+0j) [Y0 Z2 Z3 Y5] +
    (0.012910789308670843+0j) [Z0 Y1 Z2 Z3 Z4] +
    (-0.012910780273117513+0j) [Z0 Y1 Z3 Z5] +
    (-0.0029329964409502284+0j) [Z0 Z1 X2 X3 Z4 Z5] +
    (0.1619947538800418+0j) [Z0 Z1 Z2] +
    (0.012910789308670843+0j) [Z0 Z1 Y4] +
    (0.11412688446849814+0j) [Z0 Z1 Z4 Z5] +
    (-0.0029329964409502284+0j) [Z0 Y2 Y3 Z5] +
    (0.10826089158659767+0j) [Z0 Z2 Z3 Z5] +
    (-0.012910780273117513+0j) [Z0 Z3 Y4 Z5] +
    (0.1619947538800418+0j) [Z0 Z3 Z4] +
    (0.012910780273117513+0j) [Y1 Z2] +
    (-0.012910789308670843+0j) [Y1 Z4 Z5] +
    (-0.0029329964409502284+0j) [Z1 Y2 Y3 Z4] +
    (-0.012910789308670843+0j) [Z1 Z2 Z3 Y4 Z5] +
    (0.2092437967680857+0j) [Z1 Z2 Z3 Z4] +
    (-0.013243698330265938+0j) [Z1 Z3 Z5] +
    (0.012910780273117513+0j) [Z2 Y4] +
    (-0.013243698330265952+0j) [Z2 Z4 Z5]

## Example 3: 1D Free Electron Gas (Jellium)

```python
from openfermion.hamiltonians import jellium_model
from openfermion.utils import eigenspectrum, fourier_transform, Grid
from openfermion.transforms import jordan_wigner

# Let's look at a very small model of jellium in 1D.
grid = Grid(dimensions=1, length=3, scale=1.0)
spinless = True

# Get the momentum Hamiltonian.
momentum_hamiltonian = jellium_model(grid, spinless)
momentum_qubit_operator = jordan_wigner(momentum_hamiltonian)
momentum_qubit_operator.compress()
print(momentum_qubit_operator)

# Fourier transform the Hamiltonian to the position basis.
position_hamiltonian = fourier_transform(momentum_hamiltonian, grid, spinless)
position_qubit_operator = jordan_wigner(position_hamiltonian)
position_qubit_operator.compress()
print('')
print (position_qubit_operator)

# Check the spectra to make sure these representations are iso-spectral.
spectral_difference = eigenspectrum(momentum_qubit_operator) -  eigenspectrum(position_qubit_operator)
print('')
print(spectral_difference)
```

    19.50047638754088 [] +
    -9.71044945799746 [Z0] +
    -0.07957747154594767 [Z0 Z1] +
    -0.07957747154594767 [Z0 Z2] +
    0.15915494309189535 [Z1] +
    -0.07957747154594767 [Z1 Z2] +
    -9.71044945799746 [Z2]

    19.500476387540857 [] +
    -3.289868133696451 [X0 X1] +
    -3.2898681336964564 [X0 Z1 X2] +
    -3.289868133696451 [Y0 Y1] +
    -3.2898681336964564 [Y0 Z1 Y2] +
    -6.42058132430101 [Z0] +
    -0.07957747154594766 [Z0 Z1] +
    -0.07957747154594767 [Z0 Z2] +
    -3.289868133696451 [X1 X2] +
    -3.289868133696451 [Y1 Y2] +
    -6.4205813243010095 [Z1] +
    -0.07957747154594766 [Z1 Z2] +
    -6.42058132430101 [Z2]

    [2.92682545e-14 2.83939539e-14 2.84217094e-14 1.42108547e-14
     2.48689958e-14 1.77635684e-14 7.10542736e-15 1.42108547e-14]

## Appendix: Define Specific Fermionic Operator for JW and BK Testing

```python
from openfermion.ops import FermionOperator
from openfermion.transforms import jordan_wigner, bravyi_kitaev
from openfermion.utils import eigenspectrum, hermitian_conjugated

# Initialize an operator.
fermion_operator = FermionOperator('2^ 0', 3.17)
fermion_operator += hermitian_conjugated(fermion_operator)
print(fermion_operator)

# Transform to qubits under the Jordan-Wigner transformation and print its spectrum.
jw_operator = jordan_wigner(fermion_operator)
print()
print(jw_operator)

# Transform to qubits under the Bravyi-Kitaev transformation and print its spectrum.
bk_operator = bravyi_kitaev(fermion_operator)
print()
print(bk_operator)
```

    3.17 [0^ 2] +
    3.17 [2^ 0]

    (1.585+0j) [X0 Z1 X2] +
    (1.585+0j) [Y0 Z1 Y2]

    (1.585+0j) [X0 Y1 Y2] +
    (-1.585+0j) [Y0 Y1 X2]


# Mass Maps From Mass-Luminosity Inference Posterior

In this notebook we start to explore the potential of using a mass-luminosity relation posterior to refine mass maps.

Content:
- [Math](#Math)
- [Imports, Constants, Utils, Data](#Imports,-Constants,-Utils,-Data)
- [Probability Functions](#Probability-Functions)
- [Results](#Results)
- [Discussion](#Discussion)

### Math

Infering mass from mass-luminosity relation posterior ...

\begin{align}
P(M|L_{obs},z,\sigma_L^{obs}) &= \iint P(M|\alpha, S, L_{obs}, z)P(\alpha, S|L_{obs},z,\sigma_L^{obs})\ d\alpha dS\\
&\propto \iiint P(L_{obs}| L,\sigma_L^{obs})P(L|M,\alpha,S,z)P(M|z)P(\alpha, S|L_{obs},z,\sigma_L^{obs})\ dLd\alpha dS\\
&\approx \frac{P(M|z)}{n_{\alpha,S}}\sum_{\alpha,S \sim P(\alpha, S|L_{obs},z,\sigma_L^{obs})}\left( \frac{1}{n_L}\sum_{L\sim P(L|M,\alpha,S,z)}P(L_{obs}|L,\sigma_L^{obs})\right)\\
&= \frac{P(M|z)}{n_{\alpha,S}}\sum_{\alpha,S \sim P(\alpha, S|L_{obs},z,\sigma_L^{obs})}f(M;\alpha,S,z)\\
\end{align}

Refine for individual halo ...

\begin{align}
P(M_k|L_{obs},z,\sigma_L^{obs}) &= \iint P(M_k|\alpha, S, L_{obs\ k}, z_k)P(\alpha, S|L_{obs},z,\sigma_L^{obs})\ d\alpha dS\\
&\propto \iiint P(L_{obs\ k}| L_k,\sigma_L^{obs})P(L_k|M_k,\alpha,S,z_k)P(M_k|z_k)P(\alpha, S|L_{obs},z,\sigma_L^{obs})\ dLd\alpha dS\\
&\approx \frac{P(M_k|z_k)}{n_{\alpha,S}}\sum_{\alpha,S \sim P(\alpha, S|L_{obs},z,\sigma_L^{obs})}\left( \frac{1}{n_L}\sum_{L\sim P(L_k|M_k,\alpha,S,z_k)}P(L_{obs\ k}|L_k,\sigma_L^{obs})\right)\\
&=\frac{P(M_k|z_k)}{n_{\alpha,S}}\sum_{\alpha,S \sim P(\alpha, S|L_{obs},z,\sigma_L^{obs})}f(M_k;\alpha,S,z_k)\\
\end{align}

Can also factor it more conventionally for MCMC ...

\begin{align}
\underbrace{P(M_k|L_{obs},z,\sigma_L^{obs})}_{posterior}
&\propto \underbrace{P(M_k|z_k)}_{prior}\underbrace{\iiint P(L_{obs\ k}| L_k,\sigma_L^{obs})P(L_k|M_k,\alpha,S,z_k)P(\alpha, S|L_{obs},z,\sigma_L^{obs})\ dLd\alpha dS}_{likelihood}\\
\end{align}

In the code we have the following naming convention:
- p1 for $P(M|z)$
- p2 for $P(\alpha, S|L_{obs},z,\sigma_L^{obs})$
- p3 for $P(L_k|M_k,\alpha,S,z_k)$
- p4 for $P(L_{obs\ k}|L_k, \sigma^{obs}_L)$

We use the terms **eval** and **samp** to help distinguish between evaluating a distribution and sampling from it.

### Imports, Constants, Utils, Data

```python
%matplotlib inline

import matplotlib.pyplot as plt
from matplotlib import rc
rc('text', usetex=True)
from bigmali.grid import Grid
from bigmali.prior import TinkerPrior
from bigmali.hyperparameter import get
import numpy as np
from scipy.stats import lognorm
from numpy.random import normal

#globals that functions rely on
grid = Grid()
prior = TinkerPrior(grid)
a_seed = get()[:-1]
S_seed = get()[-1]
mass_points = prior.fetch(grid.snap(0)).mass[2:-2] # cut edges
tmp = np.loadtxt('/Users/user/Code/PanglossNotebooks/MassLuminosityProject/SummerResearch/mass_mapping.txt')
z_data = tmp[:,0]
lobs_data = tmp[:,1]
mass_data = tmp[:,2]
ra_data = tmp[:,3]
dec_data = tmp[:,4]
sigobs = 0.05

def fast_lognormal(mu, sigma, x):
    return  (1/(x * sigma * np.sqrt(2 * np.pi))) * np.exp(- 0.5 * (np.log(x) - np.log(mu)) ** 2 / sigma ** 2)
```

### Probability Functions

```python
def p1_eval(zk):
    return prior.fetch(grid.snap(zk)).prob[2:-2]

def p2_samp(nas=100):
    """
    a is fixed on hyperseed,
    S is normal distribution centered at hyperseed.
    """
    return normal(S_seed, S_seed / 10, size=nas)

def p3_samp(mk, a, S, zk, nl=100):
    mu_lum = np.exp(a[0]) * ((mk / a[2]) ** a[1]) * ((1 + zk) ** (a[3]))
    return lognorm(S, scale=mu_lum).rvs(nl)

def p4_eval(lobsk, lk, sigobs):
    return fast_lognormal(lk, sigobs, lobsk)

def f(a, S, zk, lobsk, nl=100):
    ans = []
    for mk in mass_points:
        tot = 0
        for x in p3_samp(mk, a, S, zk, nl):
            tot += p4_eval(lobsk, x, sigobs)
        ans.append(tot / nl)
    return ans

def mass_dist(ind=1, nas=10, nl=100):
    lobsk = lobs_data[ind]
    zk = z_data[ind]
    tot = np.zeros(len(mass_points))
    for S in p2_samp(nas):
        tot += f(a_seed, S, zk, lobsk, nl)
    prop = p1_eval(zk) * tot / nas
    return prop / np.trapz(prop, x=mass_points)
```

### Results

```python
plt.subplot(3,3,1)
dist = p1_eval(zk)
plt.plot(mass_points, dist)
plt.gca().set_xscale('log')
plt.gca().set_yscale('log')
plt.ylim([10**-25, 10])
plt.xlim([mass_points.min(), mass_points.max()])
plt.title('Prior')
plt.xlabel(r'Mass $(M_\odot)$')
plt.ylabel('Density')

for ind in range(2,9):
    plt.subplot(3,3,ind)
    dist = mass_dist(ind)
    plt.plot(mass_points, dist, alpha=0.6, linewidth=2)
    plt.xlim([mass_points.min(), mass_points.max()])
    plt.gca().set_xscale('log')
    plt.gca().set_yscale('log')
    plt.ylim([10**-25, 10])
    plt.gca().axvline(mass_data[ind], color='red', linewidth=2, alpha=0.6)
    plt.title('Mass Distribution')
    plt.xlabel(r'Mass $(M_\odot)$')
    plt.ylabel('Density')

# most massive
ind = np.argmax(mass_data)
plt.subplot(3,3,9)
dist = mass_dist(ind)
plt.plot(mass_points, dist, alpha=0.6, linewidth=2)
plt.gca().set_xscale('log')
plt.gca().set_yscale('log')
plt.xlim([mass_points.min(), mass_points.max()])
plt.ylim([10**-25, 10])
plt.gca().axvline(mass_data[ind], color='red', linewidth=2, alpha=0.6)
plt.title('Mass Distribution')
plt.xlabel(r'Mass $(M_\odot)$')
plt.ylabel('Density')

# plt.tight_layout()
plt.gcf().set_size_inches((10,6))
```

### Turning into Probabilistic Catalogue

```python
index = range(2,9) + [np.argmax(mass_data)]

plt.title('Simple Sketch of Field of View')
plt.scatter(ra_data[index], dec_data[index] , s=np.log(mass_data[index]), alpha=0.6)
plt.xlabel('ra')
plt.ylabel('dec');
```

Need to build graphic:
- Make grid that will correspond to pixels
- map ra-dec window to grid
- snap objects onto grid and accumulate mass in each bin of the grid
- plot the grayscale image

# Discussion

- While this is a simple toy model, the consistency between then predicted mass distribution and true mass is encouraging.
- The noise in the mass distribution plots is interesting. The noise increases for masses that are further away than the truth. A similar effect may also exist in bigmali, could it lead to a failure mode?
- In order to build probabilistic mass maps we will need to be able to sample from the mass distributions. One way to do this would be fitting a normal distribution and drawing from that distribution. This will also mitigate the influence of the noise for masses far from the true mass.


# Finding the ground state energy of a two qubits system via VQE

As it is well-known, the VQE algorithm makes use of the variational principle to approximate the ground state of a given quantum hamiltonian, $H$ [[1](https://www.nature.com/articles/ncomms5213?origin=ppub)]. To make a long story short, the variational principle ensures that, given a certain quantum state $\left|\psi \right\rangle$, it satisfies the following inequivalence (we assume normalized kets):

$$
\left\langle \psi \right| H \left| \psi \right\rangle \leq E_0 \ ,
$$

where we denoted with $E_0$ the ground state energy and the equivalence is satisfied if $\left|\psi \right\rangle$ actually corresponds to ground state of the system.

It is immediate to see that the variational principle provides an effective way to estimate the ground state of the system under investigation: by simply taking a <i>set</i> of kets $\left|\psi(\alpha , \, \beta , \, \dots) \right\rangle$, depending on some parameters $(\alpha , \, \beta , \, \dots)$, one can find the best approximation to the ground state by simply evaluating the expression $\left\langle \psi (\alpha , \, \beta , \, \dots) \right| H \left|\psi (\alpha , \, \beta , \, \dots) \right\rangle$ and looking for its minimum. Of course, in order to find a good approximation to the ground state, one has to choose properly the ansatz $\left|\psi(\alpha , \, \beta , \, \dots) \right\rangle$.

Our goal in this notebook, is to consider some simple $4 \times 4$ matrices and to give an estimate of their ground state energies, via a VQE algorithm written using Qiskit. The analysis performed in this notebook can be considered as a very small extension to the case of two qubits of the nice analysis of  [[2](https://github.com/DavitKhach/quantum-algorithms-tutorials/blob/master/variational_quantum_eigensolver.ipynb)], to which we refer for a more detailed explanation of the basis of the VQE algorithm.

Let us start by constructing a first example of matrix to diagonalize. It reads

$$
   M=
  \left[ {\begin{array}{cccc}
   0 & 0 & 0 & 0 \\
   0 & -1 & 1 & 0 \\
   0 & 1 & -1 & 0 \\
   0 & 0 & 0 & 0 \\
  \end{array} } \right] \ ,
$$

and, to start with, we need to decompose it in terms of tensor products of Pauli operators.
Given that the matrix is $4 \times 4$, it means that can be written as sum of tensor products of two Pauli operators. In other words, it can be viewed as a two-qubits operator.

Here we put all the packages we need:

```python
import numpy as np
from random import random
from scipy.optimize import minimize
from sympy.solvers import solve
from sympy import Symbol

from qiskit import *
from qiskit.aqua.algorithms import ExactEigensolver
from qiskit.quantum_info.operators import Operator , Pauli
from qiskit.aqua.operators import WeightedPauliOperator
```

Let us construct the matrix of the coefficients, as a numpy array:

```python
M = np.array([[0. , 0. , 0. , 0.], [0. , -1. , 1. , 0.] , [0. , 1. , -1. , 0.] , [0. , 0. , 0. , 0.]])
```

We now define a function which transforms a $4 \times 4$ matrix to the tensor decomposition in terms of the Pauli operators.

```python
def hamiltonian_operator(matrix):
    list_labels = []
    pauli_operator_list = []
    symbols_list = []
    equations = []
    list_dict = []
    unknown_matrix = 0
    #these are the building blocks of the Pauli objects we are going to have, like II
    pauli_names = ('I','X','Y','Z')
    for i in range(0,4):
        for j in range(0,4):
            #we create the list of the labels
            list_labels.append(pauli_names[i] + pauli_names[j])
            pauli_operator_list.append(Operator(Pauli(label=pauli_names[i] + pauli_names[j])))
    for i in range(0,16):
        # we create the list of symbols, which we need to solve the equations below
        symbols_list.append(Symbol(list_labels[i]))
    for i in range(0,16):
        # we create the matrix containing the various pauli operators and the unknown
        unknown_matrix = unknown_matrix + (Symbol(list_labels[i])) * pauli_operator_list[i].data
    for i in range(0, 4):
        for j in range(0,4):
            # we create the list of equations
            equations.append(unknown_matrix[i,j] - matrix[i,j])
    sol = solve(equations , symbols_list, manual = True)
    # we create the dictionary to give to the WeightedPauliOperator
    for i in range(0,16):
        list_dict.append({"coeff": {"imag": 0.0, "real": sol[0][i]}, "label": list_labels[i]})
    pauli_dict = {
        'paulis': list_dict
    }
    return WeightedPauliOperator.from_dict(pauli_dict)
```

and we use this function just defined to transform our matrix $M$ in the sum of Pauli operators

```python
H = hamiltonian_operator(M)
```

For future comparison, we compute the ground state energy via the ExactEigensolver class

```python
exact_eigensolver = ExactEigensolver(H)
result_ee = exact_eigensolver.run()
reference_energy = result_ee['energy']
```

As already mentioned, a crucial ingredient in the VQE algorithms is the choice of the ansatz: indeed, it is important to choose an ansatz which includes the ground state or, at least, which can be very close to the true ground state of the system, in order to have a good estimate of the ground state energy.

Restricting our attention to the problem under consideration, it is immediate to recognize that the ground state is given by the state

$$
\left| \psi_0 \right\rangle = \frac{1}{\sqrt 2} \left( \left| 10 \right\rangle - \left| 01 \right\rangle \right) \ ,
$$

where, with the notation $\left| 10 \right\rangle$ we mean the states defined in the basis with definite spin along the $z$-axes.

It is immediate to see that to build the ground state via a quantum cirquit starting from the state $\left| 00 \right\rangle$, a Hadamard gate, and a subsequent CNOT gate are necessary, in order to make a superposition and to create the entanglement. Hence, we will work with the following ansatz:

$$
\left( R_z \left(\alpha\right) \, I \right)\left( R_x \left(\beta \right) \, I \right) CX \left( HI \right) \, \left| 00 \right\rangle \ ,
$$

where the last two gates determine the relative angle betweent the two qubits. Given this ansatz, we now define a function which, given two parameters, prepares the quantum state associated to the snsatz, together with the two classical bits for the measurements.

```python
def quantum_ansatz(param):
    circ = QuantumCircuit(2,2)
    # Add a H gate on qubit 0, putting this qubit in a superposition.
    circ.h(0)
    # Add a CX (CNOT) gate on control qubit 0 and target qubit 1, putting
    # the qubits in a Bell state and creating then entanglement.
    circ.cx(0, 1)
    # and now we create the part of the Ansatz controlled by the parameters:
    #we do an Rx rotation and an Rz rotation (in this order!)
    circ.rx(param[0],0)
    circ.rz(param[1],0)
    return circ
```

Although the Qiskit built-in method .to_dict() allows us to convert our hamiltonian H (which is a WeightedPauliOperator) to a dictionary of this form

```python
dict_long = H.to_dict()
dict_long
```

    {'paulis': [{'label': 'II', 'coeff': {'real': -0.5, 'imag': 0.0}},
      {'label': 'XX', 'coeff': {'real': 0.5, 'imag': 0.0}},
      {'label': 'YY', 'coeff': {'real': 0.5, 'imag': 0.0}},
      {'label': 'ZZ', 'coeff': {'real': 0.5, 'imag': 0.0}}]}

we prefer to prepare a new function to get from H a list of dictionaries, a bit lighter, called pauli_dict

```python
def pauli_operator_to_dict(pauli_operator):

    """
    This function eats our hamiltonian, constructed previously, and converts it to a list of dictionaries:
    [{'label': 'II', 'coeff': -0.5},
 {'label': 'XX', 'coeff': 0.5},
 {'label': 'YY', 'coeff': 0.5},
 {'label': 'ZZ', 'coeff': 0.5}].
    """

    dict_long = pauli_operator.to_dict()
    paulis = dict_long['paulis']
    paulis_dict = []

    for x in paulis:
        label = x['label']
        coeff = x['coeff']['real']
        paulis_dict.append({'label' : label , 'coeff' : coeff})

    return paulis_dict
pauli_dict = pauli_operator_to_dict(H)
```

When working with dictionaries, it is good to have a function which, given a label, returns the value associated to that label or returns $0$ if that label does not exist in the dictionary

```python
def get_or_else_zero(d: dict, key: str):

    """
    Utility for working with dictionaries. If key is missing
    than return 0 otherwise the corresponding value.
    :param dict: the dictionary.
    :param key: key (string) in interest.
    :return: 0 or value of corresponding key.
    """

    value = 0
    if key in d:
        value = d[key]
    return value
```

Now, we define a function that, given a circuit, a qubit, and a measurement to do, appends the gate associated with the measurements we are going to do on the qubit and connect the qubit to the corresponding classical bit

```python
def measurement_preparation_circuit(circuit, measure : str, qubit):

    """
    Create the preparation for a measurement: it adds the gates
    necessary to convert the circuit to the appropriate
    computational basis and connect the qubits with the corresponding classical bits
    """

    if measure == 'Z':
        circuit.measure(qubit, qubit)
    elif measure == 'X':
        circuit.u2(qubit, np.pi, qubit)
        circuit.measure(qubit, qubit)
    elif measure == 'Y':
        circuit.u2(qubit, np.pi/2, qubit)
        circuit.measure(qubit, qubit)

    return circuit
```

Let us now define the function associated with the quantum part of the algorithm. This function prepares the quantum circuit and performs the quantum experiment associated with a certain measurement. In the case the measurement involves an identity operator on one of the two qubits, we do the measurement on the other qubit only

```python
def quantum_module(parameters, measure):

    # we check the presence of identity operators
    if measure == 'II':
        return 1 # if both operators are identity there is nothing to measure
    elif measure[0] == 'I':
        check_identity = 2
    elif measure[1] == 'I':
        check_identity = 1
    else:
        check_identity = 0

    # quantum state preparation
    circuit = quantum_ansatz(parameters)

    for i in range(0,2):
        circuit = measurement_preparation_circuit(circuit, measure[i] , i)

    shots = 2000
    backend = BasicAer.get_backend('qasm_simulator')
    job = execute(circuit, backend, shots=shots)
    result = job.result()
    counts = result.get_counts()

    if check_identity == 0:
        expectation_value = (get_or_else_zero(counts, '00') + get_or_else_zero(counts, '11')  - get_or_else_zero(counts,'01') -  get_or_else_zero(counts,'10')) / shots
    elif check_identity == 2:
        expectation_value = (get_or_else_zero(counts, '00') - get_or_else_zero(counts,'10')) / shots
    elif check_identity == 1:
        expectation_value = (get_or_else_zero(counts, '00') - get_or_else_zero(counts,'01')) / shots

    return expectation_value
```

We now define the main function, which sums the results of the measurements perfomed on the various terms of the hamiltonian

```python
def vqe(parameters):

    classical_adder = 0

    for i in range(0, len(pauli_dict)):
        classical_adder = classical_adder + pauli_dict[i]['coeff'] * quantum_module(parameters, pauli_dict[i]['label'])

    return classical_adder
```

and finally, we are ready to compute the ground state with the VQE algorithm. We use two methods: in the first we simply scan all the values of the parameters for our ansatz, in the second we use the minimize() method of scipy.

```python
n_spacing_one = 40
n_spacing_two = 40
parameters_array_one = np.linspace(0, 2 * np.pi, n_spacing_one)
parameters_array_two = np.linspace(0, np.pi, n_spacing_two)
sol_list = []

for i in range(0 , n_spacing_one):
    for j in range(0 , n_spacing_two):
        sol_list.append(vqe([parameters_array_one[i] , parameters_array_one[j]]))

print('The exact ground state energy is: {}'.format(reference_energy))
print('The estimated ground state energy using the VQE algorithm is: {}'.format((sorted(sol_list))[0]))
```

    The exact ground state energy is: -2.0
    The estimated ground state energy using the VQE algorithm is: -1.9975

```python
parameters_array = np.array([np.pi, np.pi]) # starting point for the parameters
tol = 1e-5 # tolerance for the required precision.

vqe_result = minimize(vqe, parameters_array, method = 'Powell',tol=tol)
print('The exact ground state energy is: {}'.format(reference_energy))
print('The estimated ground state energy from VQE algorithm and by minimization method is: {}'.format(vqe_result.fun))
```

    The exact ground state energy is: -2.0
    The estimated ground state energy from VQE algorithm and by minimization method is: -2.0

as we can see, the quality of the approximation is extremely good. This is not surprising, since the ground state falls in the ansatz we used.

We can generalize a bit our algorithm and consider more general matrices: we keep a matrix with the same structure of non-vanishing coefficients, but we replace the coefficients with random numbers (but we impose the restriction that the matrix is symmetric). We can also add few terms on the main diagonal

```python
a = random()
b = random()

M = np.array([[a , 0. , 0 , 0.], [0. , a , b , 0.] , [0 , b , a , 0.] , [0. , 0. , 0. , b]])
H = hamiltonian_operator(M)

exact_eigensolver = ExactEigensolver(H)
result_ee = exact_eigensolver.run()
reference_energy = result_ee['energy']

pauli_dict = pauli_operator_to_dict(H)
pauli_dict
```

    [{'label': 'II', 'coeff': 0.6761974355732028},
     {'label': 'IZ', 'coeff': 0.09643075333862625},
     {'label': 'XX', 'coeff': 0.193452587778662},
     {'label': 'YY', 'coeff': 0.193452587778662},
     {'label': 'ZI', 'coeff': 0.09643075333862625},
     {'label': 'ZZ', 'coeff': -0.09643075333862625}]

```python
n_spacing_one = 40
n_spacing_two = 40
parameters_array_one = np.linspace(0, 2 * np.pi, n_spacing_one)
parameters_array_two = np.linspace(0, np.pi, n_spacing_two)
sol_list = []

for i in range(0 , n_spacing_one):
    for j in range(0 , n_spacing_two):
        sol_list.append(vqe([parameters_array_one[i] , parameters_array_one[j]]))

print('The exact ground state energy is: {}'.format(reference_energy))
print('The estimated ground state energy using the VQE algorithm is: {}'.format((sorted(sol_list))[0]))
```

    The exact ground state energy is: 0.38572301335450504
    The estimated ground state energy using the VQE algorithm is: 0.381385402697571

Once again, the approximation is pretty good in finding the ground state energy. However, notice that by taking a slightly more general matrix, our Ansatz for the ground state stops its validity and the approximation to the ground state is very bad.

```python
a = random()
b = random()
c = random()

M = np.array([[0 , 0. , c , 0.], [0. , a , b , 0.] , [c , b , a , 0.] , [0. , 0. , 0. , 0]])
H = hamiltonian_operator(M)

exact_eigensolver = ExactEigensolver(H)
result_ee = exact_eigensolver.run()
reference_energy = result_ee['energy']

pauli_dict = pauli_operator_to_dict(H)
pauli_dict
```

    [{'label': 'II', 'coeff': 0.374377988110401},
     {'label': 'XI', 'coeff': 0.3654025704062155},
     {'label': 'XX', 'coeff': 0.4733816162542325},
     {'label': 'XZ', 'coeff': 0.3654025704062155},
     {'label': 'YY', 'coeff': 0.4733816162542325},
     {'label': 'ZZ', 'coeff': -0.374377988110401}]

```python
n_spacing_one = 40
n_spacing_two = 40
parameters_array_one = np.linspace(0, 2 * np.pi, n_spacing_one)
parameters_array_two = np.linspace(0, np.pi, n_spacing_two)
sol_list = []

for i in range(0 , n_spacing_one):
    for j in range(0 , n_spacing_two):
        sol_list.append(vqe([parameters_array_one[i] , parameters_array_one[j]]))

print('The exact ground state energy is: {}'.format(reference_energy))
print('The estimated ground state energy using the VQE algorithm is: {}'.format((sorted(sol_list))[0]))
```

    The exact ground state energy is: -0.6738697832124032
    The estimated ground state energy using the VQE algorithm is: -0.2599258073107657

[1] [A. Peruzzo et al., Nature Communications, "A variational eigenvalue solver on a photonic quantum processor" (2014).](https://www.nature.com/articles/ncomms5213?origin=ppub)

[2] [D. Khachatryan, "Variational quantum eigensolver".](https://github.com/DavitKhach/quantum-algorithms-tutorials/blob/master/variational_quantum_eigensolver.ipynb)