# Death by Asteroid

### Bennett Taylor and Olivia Seitelman

```python
# Configure Jupyter so figures appear in the notebook
%matplotlib inline

# Configure Jupyter to display the assigned value after an assignment
%config InteractiveShell.ast_node_interactivity='last_expr_or_assign'

# import functions from the modsim.py module
from modsim import *
import math as math

import numpy as np
import matplotlib.pyplot as ply
import scipy, pylab
```

## Question

If an asteroid is passing Earth at a set distance, at what velocity would it enter Earth's orbit or collide with Earth?

This question is important to everyone on Earth, as asteroids pose a threat to human life and their impact can be predicted and prevented.

## Model

We chose to model an asteroid that is approaching the Earth at a varying velocity and will pass tangentially at a set distance. The asteroid starts at `3e8 * m`, which is almost the distance to the moon, and the only force acting on the asteroid is Earth's gravity. Our asteroid is the size of the one that killed the dinosaurs, with a radius of `5000 *m` and a mass of about `6.1e15 * kg`.

To model the asteroid, we created universal gravitation and slope functions to determine the force on the asteroid, and then used the ode solver. We used a sweepseries to sweep velocity values and graphed their different orbits.

### Schematic

Our model follows a fairly straigh forward phenomenon:the gravitational attraction of the Earth on a passing asteroid.

### Differential Equations
\begin{align}
\frac{dv}{dt} = G \times \frac{m_1 \times m_2}{r^2} \\
\frac{dy}{dt} = v
\end{align}

We used the universal gravitation equation, converting force into the change in velocity over time. For the purposes of using run_ode_solver, we then turned this into a derivative of position, as to have it running a first order differential equation.

### Python

Having sketched a schematic and determined our equations, we then went to work writing the model in Python.

```python
#the units we will be using throughout
m = UNITS.meter
s = UNITS.second
kg = UNITS.kilogram
AU = UNITS.astronomical_unit
N = UNITS.newton
```

newton

First, we will define our state. The asteroid starts at `(300000 * m, 300000 * m)` with an initial velocity of `-1000 * m/s` in the y direction.

```python
#asteroid is starting approximately "r" away in the x and y distance
px_0 = 300000 * m
py_0 = 300000 * m
vx_0 = 0 * m/s
vy_0 = -1000 * m/ s
init = State(px=px_0,
             py=py_0,
             vx=vx_0,
             vy=vy_0)
```

      values

      px
      300000 meter

      py
      300000 meter

      vx
      0.0 meter / second

      vy
      -1000.0 meter / second

Next, we create our system using a make system function from the state variables we have previously defined.  We will define the universal gravitation constant, the masses of the bodies, the initial and final times, and the combined radii of the earth and asteroid to help define the event function.

```python
def make_system(px_0, py_0, vx_0, vy_0):

    init = State(px=px_0 * m,
         py=py_0 * m,
         vx=vx_0 * m/s,
         vy=vy_0 * m/s)

        #universal gravitation value
    G = 6.67408e-11*N/(kg**2 * m**2)

         #mass of asteroid that killed dinosaurs
    m1 = 6.1e15* kg

        #earth mass
    m2 = 5.972324e24* kg

        #intial and final time (0s and 1 year in seconds)
    t_0 = 0 * s
    t_end = 315360000 * s

        #radius of earth plus radius of asteroid that killed the dinosaurs
    r_final = 6376000 * m

    print(init)
    return System(init=init, G=G, m1=m1, m2=m2, t_0=t_0, t_end=t_end, r_final=r_final)
```

We then made the system with our chosen values of our state variables.

```python
    #asteroid is starting approximately one moon's distance away in the
    #x and y distance (selected for feasability, asteroids would rarely pass closer)
system = make_system(300000, 300000, 0, -1000)
```

    px              300000 meter
    py              300000 meter
    vx        0.0 meter / second
    vy    -1000.0 meter / second
    dtype: object

      values

      init
      px              300000 meter
py              3...

      G
      6.67408e-11 newton / kilogram ** 2 / meter ** 2

      m1
      6100000000000000.0 kilogram

      m2
      5.972324e+24 kilogram

      t_0
      0 second

      t_end
      315360000 second

      r_final
      6376000 meter

We define the universal gravitation function to determine the force on the asteroid caused by the Earth.

```python
def universal_gravitation(state, system):
    #position and velocity in x and y directions
    px, py, vx, vy = state
    unpack(system)

    #divide magnitude of position by vector  of position to find direction
    position = Vector(px, py)
    P = sqrt(px**2/m**2 + py**2/m**2)
    #Calculate magnitude of gravitational force
    F_magnitude = G * m1 * m2/ ((P)**2)

    P_direction = position/P

    #give direction to force magnitude, make it a force vector
    F = P_direction * (-1) * F_magnitude * m

    return F
```

```python
universal_gravitation(init, system)
```

[-9.55162141e+18 -9.55162141e+18] newton

We create an event function to stop the ode solver just before the asteroid hits the earth, meaning that the asteroid is at `r_final`, the sum of the radii of the earth and the asteroid.

```python
#this did not end up functioning as we had desired, but is left in incase of use in future model
def event_func(state, t, system):

    px, py, vx, vy = state

    #find absolute value of position relative to earth
    P = abs(sqrt(px**2 + py**2))

    #return zero when distance equals distance between the center points of the asteroid and the earth
    #(when they are touching)
    return P - abs(system.r_final - 1)
```

```python
universal_gravitation(init, system)
```

[-9.55162141e+18 -9.55162141e+18] newton

The slope function returns derivatives that can be processed by the ode solver.

```python
def slope_func(state, t, system):
    px, py, vx, vy = state
    unpack(system)

    #combind x and y components to make one position value
    position = Vector(px, py)

    #set force using universal ravitation function
    F = universal_gravitation(state, system)

    #seperate force into x and y components
    Fx = F.x
    Fy = F.y

    #set chain of differentials, so acceleration (Force divided by mass) is equal to dv/dt
    #and v is equal to dpdt, each in x and y components
    dpxdt = vx

    dpydt = vy

    dvxdt = Fx/m1

    dvydt = Fy/m1

    return dpxdt, dpydt, dvxdt, dvydt
```

Calling the slope function should return the x and y velocities we set and the force in the x and y directions, and checking this proves it true

```python
#test slope func
slope_func(init, 0, system)
```

    (<Quantity(0.0, 'meter / second')>,
     <Quantity(-1000.0, 'meter / second')>,
     <Quantity(-1565.839575767627, 'newton / kilogram')>,
     <Quantity(-1565.839575767627, 'newton / kilogram')>)

```python
#test gravity value
grav = universal_gravitation(init, system)
```

[-9.55162141e+18 -9.55162141e+18] newton

The ode solver will return values for x position, y position, x velocity, and y velocity as the asteroid moves through space. X position and y position will then be divided by 1e9 such that they are expresses in millions of kilometers.

```python
#run ode, with results scaled down to millions
results, details = run_ode_solver(system, slope_func, vectorized = True, events = event_func)
results.px/=1e6
results.py/=1e6
```

```python
#note that success is listed as false
#the ode solver would not allow the asteroid to hit the earth, no matter how we changed the event function or equations
#we later impliment an if then statement into the return to work around this
print(details)
results.tail()
```

    sol                                                      None
    t_events                                                 [[]]
    nfev                                                    23444
    njev                                                        0
    nlu                                                         0
    status                                                     -1
    message     Required step size is less than spacing betwee...
    success                                                 False
    dtype: object

      px
      py
      vx
      vy

      17.664187
      -8.135517e-11
      2.326817e-11
      6.475701e+08
      2.092805e+09

      17.664187
      -2.202471e-11
      8.062377e-11
      2.128075e+09
      5.627940e+08

      17.664187
      5.772468e-11
      6.016299e-11
      1.572694e+09
      -1.524570e+09

      17.664187
      8.081795e-11
      -1.841503e-11
      -5.172692e+08
      -2.125779e+09

      17.664187
      2.401861e-11
      -7.774800e-11
      -2.129400e+09
      -5.942638e+08

Plotting x position and y position separately against time produces two nearly identical lines that shown the asteroid getting closer and closer to the earth and then orbiting it in an off center ellipse until it inevitably collides.

```python
#plot x and y relative to time
results.px.plot()
results.py.plot()
decorate(ylabel='Distance to Earth [thousands of km]',
        xlabel='Time [billions of s]',
         title='Path of Asteroid')
```

We can also plot the asteroid on x and y axes to see its motion towards the Earth.

```python
#plot x vs y to find full position
plot(results.px,results.py)
decorate(xlabel='Distance [millions of km]',
         ylabel='Distance [millions of km]',
         title='Path of Asteroid')
```

We can limit the axes to zoom in on its impact with the Earth, which is at the center of the ellipses.

```python
axes = plt.gca()
axes.set_xlim([0, 0.005])
axes.set_ylim([0, 0.005])
plot(results.px,results.py)
decorate(xlabel='Distance [millions of km]',
         ylabel='Distance [millions of km]',
         title='Path of Asteroid')
```

The event function cannot accurately determine if the asteroid hits the Earth, as the distance between the two gets so small but the event function will never let them collide. Instead of the event function, we can use a function to determine if the last value of the results for x and y are close enough to the Earth such that the asteroid will be guaranteed to hit.

```python
def collision_result(results):
    #store and print final x and y position of asteroid relative to earth
    colvalx = get_last_value(results.px)
    colvaly = get_last_value(results.py)
    print('Final X Value =', colvalx)
    print('Final Y Value =', colvaly)

    #if the asteroid is within 1 meter of the Earth after a year of orbit, it is assumed that it will hit the earth
    if -1 < colvalx and colvaly < 1:
        print ('Kaboom! The asteroid hit!')
    else:
        print ('We live to love another day!')
```

```python
#test
collision_result(results)
```

    Final X Value = 2.401860681452048e-11
    Final Y Value = -7.774799575110359e-11
    Kaboom! The asteroid hit!

## Results

Sweeping the velocity can show what happens to the asteroid at varying speeds. In this first sweeep, all 5 of the swept speeds result in the asteroid colliding with the Earth. The velocities vary from `1000 * m/s` and `10000 * m/s` in this sweep. We vary the linspace to narrow into the correct velocity or small range in which the asteroid will go into orbit.

```python
    vel_array = linspace(1000, 10000, 5)

    #sweep starting velocities between 1000 and 10000 in the y direction
for sweep_vel in vel_array:
        system = make_system(300000, 300000, 0, -1*sweep_vel)
        results, details = run_ode_solver(system, slope_func, vectorized = True, events = event_func)
        collision_result(results)

        #scale results to thousands of km and plot
        results.px/=1e3
        results.py/=1e3
        plot(results.px,results.py)
        decorate(xlabel='Distance [thousands of km]',
         ylabel='Distance [thousands of km]',
         title='Path of Asteroid')
```

The second sweep takes velocities between 10,000 m/s and 100,000 m/s. The asteroid hits the Earth at 10,000 m/s and 32,500 m/s, but not at 55,000 m/s, 77,500 m/s, or 100,000 m/s.

```python
    vel_array2 = linspace(10000, 100000, 5)

    #range of sweep altered to 10000 to 100000
for sweep_vel in vel_array2:
        system = make_system(300000, 300000, 0, -1*sweep_vel)
        results, details = run_ode_solver(system, slope_func, vectorized = True, events = event_func)

        #results scaled to millions of km, then plotted
        results.px/=1e6
        results.py/=1e6
        collision_result(results)
        plot(results.px,results.py)
        decorate(xlabel='Distance [millions of km]',
         ylabel='Distance [millions of km]',
         title='Path of Asteroid')
```

The next sweep takes a much narrower range to determine the exact point at which the asteroid narrowly misses colliding with the Earth. That velocity is shown to be somewhere in between 42098.3 m/s and 42106.0 m/s.

```python
    vel_array4 = linspace(42083.0, 42152, 10)

    #range narrowed from 42083 to 42152
for sweep_vel in vel_array4:
        system = make_system(300000, 300000, 0, -1*sweep_vel)
        results, details = run_ode_solver(system, slope_func, vectorized = True, events = event_func)
        collision_result(results)

        #results scaled to millions of km, then plotted
        results.px/=1e6
        results.py/=1e6
        plot(results.px,results.py)
        decorate(xlabel='Distance [millions of km]',
         ylabel='Distance [millions of km]',
         title='Path of Asteroid')
```

Narrowing even further, the asteroid collides when it it travelling between 42099.0 m/s and 42100.0 m/s.

```python
    vel_array5 = linspace(42098.0, 42106, 9)

    #resuts narrowed from 42098 to 42106, all integers tested
for sweep_vel in vel_array5:
        system = make_system(300000, 300000, 0, -1*sweep_vel)
        results, details = run_ode_solver(system, slope_func, vectorized = True, events = event_func)
        collision_result(results)

        #results scaled to millions of km, then plotted
        results.px/=1e6
        results.py/=1e6
        plot(results.px,results.py)
        decorate(xlabel='Distance [millions of km]',
         ylabel='Distance [millions of km]',
         title='Path of Asteroid')
```

## Interpretation

The asteroid collides with the Earth at velocities of about 42099.0 m/s and below. It slowly converges on the Earth until it eventually hits. Through a sweep of each indicidual starting velocity, we were able to determine to the integer of the velocity at what point an asteroid would be pulled in, given a set starting point.
At a speed somewhere between 42099.0 m/s and 42100.0 m/s, our model predicts that the asteroid goes into orbit.
At velocity of about 42100.0 m/s and above, the asteroid is travelling fast enough to escape Earth's gravity and does not collide.

Our model did work as expected, although we initially intended to only use the event function and then let the Earth revolve around the sun but we had to redesign our model using an if then statement and a logical assumption (that an asteroid 1 m away from the surface of the earth will collide with it) after the event function did not work.

Our model also has further application, as starting position and velocity can both be altered, with a readable and clear output of both visual and verbal confirmation. To take the model further, we would have introduced the sun and its gravity, placing the earth into orbit to make the model fully accurate to our star system, but the current iteration is sufficient for calculating rough velocities for impact.


# Integral cross section

At electron energies $11.94eV$ and $12.14eV$ comes resonant scattering by exciting $E(^3\Sigma_g^+ \nu)$ stanja molekula $N_2$. Based on symmetry, cross section follows relation:

\begin{equation}
\sigma(\theta) = A + B \cos^2\theta
\end{equation}

Based on this formula and data we can calculate integral cross section.

# Analysis

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

from scipy.optimize import curve_fit
from scipy.integrate import quad
```

```python
data_1 = pd.DataFrame({
    "theta":
        np.array([15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]),
    "cross section":
        np.array([7.17, 8.03, 7.76, 6.59, 5.68, 4.31, 2.68, 3.59, 2.2, 2.36, 3.49, 4.18])
})
```

```python
data_2 = pd.DataFrame({
    "theta":
        np.array([10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 128]),
    "cross section":
        np.array([7.2, 6.98, 5.76, 4.56, 3.01, 2.86, 1.93, 1.2, 1.2, 1.52, 2.19, 2.9, 4.06])
})
```

```python
def cross_section_fit(theta, *args):
    return args[0] + args[1] * np.cos(np.pi * (theta - args[2]) / args[3]) ** 2
```

```python
popt_1, _ = curve_fit(cross_section_fit, data_1["theta"], data_1["cross section"],
                     p0=[2.5, 5.3, -50, 140],
                     maxfev=8000)
```

```python
popt_2, _ = curve_fit(cross_section_fit, data_2["theta"], data_2["cross section"],
                     p0=[2.5, 5.3, -50, 140],
                     maxfev=8000)
```

```python
th = np.linspace(5, 135, 200)
th_ = np.linspace(0, 180, 500)
```

```python
fig, ax = plt.subplots(2)

fig.set_figwidth(15)
fig.set_figheight(10)

ax[0].scatter(data_1["theta"], data_1["cross section"], c='b', edgecolor='k')
ax[0].plot(th, cross_section_fit(th, *popt_1))

ax[1].scatter(data_2["theta"], data_2["cross section"], c='b', edgecolor='k')
ax[1].plot(th, cross_section_fit(th, *popt_2))

ax[0].set_title(r"Resonant scattering with $E=11.94eV$")
ax[1].set_title(r"Resonant scattering with $E=12.14eV$")

for axx in ax:
    axx.set_ylabel("Cross section")
    axx.set_xlabel("Angle")
    axx.grid()
```

## Extrapolated curves

```python
fig, ax = plt.subplots()

fig.set_figwidth(15)
fig.set_figheight(5)

ax.plot(th_, cross_section_fit(th_, *popt_1), label=r'$E=11.94eV$')
ax.plot(th_, cross_section_fit(th_, *popt_2), label=r'$E=12.14eV$')

ax.legend()
ax.grid()
ax.set_title(r"Resonant scattering")
ax.set_ylabel("Cross section")
ax.set_xlabel("Angle")
```

## Integrated

```python
def integrate(theta, curve, args):
    fun = lambda x: curve(x, *args)
    step_by_step = np.fromiter((quad(fun, a, b)[0]
                                for a, b in zip(theta[:-1], theta[1:])),
                               dtype=np.float64)
    return np.fromiter((np.sum(step_by_step[:i])
                        for i in range(step_by_step.shape[0])),
                       dtype=np.float64)
```

```python
i_1 = integrate(th_, cross_section_fit, popt_1)
```

```python
i_2 = integrate(th_, cross_section_fit, popt_2)
```

```python
fig, ax = plt.subplots()

fig.set_figwidth(15)
fig.set_figheight(5)

ax.plot(th_[1:], i_1, label=r'$E=11.94eV$')
ax.plot(th_[1:], i_2, label=r'$E=12.14eV$')

ax.legend()
ax.grid()
ax.set_title(r"Resonant scattering")
ax.set_ylabel("Integrated cross section")
ax.set_xlabel("Angle")
```

# Conclusion - What is cross section??

```python

```

Physics of Molecules/src/Cross section.ipynb


# Reconstruction of a complex wavefunction

In this tutorial, a walkthrough of how to reconstruct a **complex** wavefunction via training a *Restricted Boltzmann Machine* (RBM), the neural network behind QuCumber, will be presented.

## The wavefunction to be reconstructed
The simple wavefunction below describing two qubits (coefficients stored in `qubits_psi.txt`) will be reconstructed.

\begin{equation}
            \vert\psi \rangle = \alpha \vert00\rangle + \beta \vert 01\rangle + \gamma \vert10\rangle + \delta \vert11\rangle
\end{equation}

where the exact values of $\alpha, \beta, \gamma$ and $\delta$ used for this tutorial are

\begin{align}
\alpha & = 0.2861  + 0.0539 i \\
\beta &  = 0.3687 - 0.3023 i \\
\gamma & = -0.1672 - 0.3529 i \\
\delta & = -0.5659 - 0.4639 i.
\end{align}

The example dataset, `qubits_train.txt`, comprises of 500 $\sigma$ measurements made in various bases (X, Y and Z). A corresponding file containing the bases for each data point in `qubits_train.txt`, `qubits_train_bases.txt`, is also required. As per convention, spins are represented in binary notation with zero and one denoting spin-down and spin-up, respectively.

## Using qucumber to reconstruct the wavefunction

### Imports
To begin the tutorial, first import the required Python packages.

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

from qucumber.nn_states import ComplexWaveFunction

from qucumber.callbacks import MetricEvaluator

import qucumber.utils.unitaries as unitaries
import qucumber.utils.cplx as cplx

import qucumber.utils.training_statistics as ts
import qucumber.utils.data as data
```

The Python class `ComplexWaveFunction` contains generic properties of a RBM meant to reconstruct a complex wavefunction, the most notable one being the gradient function required for stochastic gradient descent.

To instantiate a `ComplexWaveFunction` object, one needs to specify the number of visible and hidden units in the RBM. The number of visible units, `num_visible`, is given by the size of the physical system, i.e. the number of spins or qubits (2 in this case), while the number of hidden units, `num_hidden`, can be varied to change the expressiveness of the neural network.

**Note:** The optimal `num_hidden` : `num_visible` ratio will depend on the system. For the two-qubit wavefunction described above, good results are yielded when this ratio is 1.

On top of needing the number of visible and hidden units, a `ComplexWaveFunction` object requires the user to input a dictionary containing the unitary operators (2x2) that will be used to rotate the qubits in and out of the computational basis, Z, during the training process. The `unitaries` utility will take care of creating this dictionary.

The `MetricEvaluator` class and `training_statistics` utility are built-in amenities that will allow the user to evaluate the training in real time.

Lastly, the `cplx` utility allows QuCumber to be able to handle complex numbers. Currently, PyTorch does not support complex numbers.

### Training
To evaluate the training in real time, the fidelity between the true wavefunction of the system and the wavefunction that QuCumber reconstructs, $\vert\langle\psi\vert\psi_{RBM}\rangle\vert^2$, will be calculated along with the Kullback-Leibler (KL) divergence (the RBM's cost function). First, the training data and the true wavefunction of this system need to be loaded using the `data` utility.

```python
train_path = "qubits_train.txt"
train_bases_path = "qubits_train_bases.txt"
psi_path = "qubits_psi.txt"
bases_path = "qubits_bases.txt"

train_samples, true_psi, train_bases, bases = data.load_data(
    train_path, psi_path, train_bases_path, bases_path
)
```

The file `qubits_bases.txt` contains every unique basis in the `qubits_train_bases.txt` file. Calculation of the full KL divergence in every basis requires the user to specify each unique basis.

As previously mentioned, a `ComplexWaveFunction` object requires a dictionary that contains the unitary operators that will be used to rotate the qubits in and out of the computational basis, Z, during the training process. In the case of the provided dataset, the unitaries required are the well-known $H$, and $K$ gates. The dictionary needed can be created with the following command.

```python
unitary_dict = unitaries.create_dict()
# unitary_dict = unitaries.create_dict(unitary_name=torch.tensor([[real part],
#                                                                 [imaginary part]],
#                                                                 dtype=torch.double)
```

If the user wishes to add their own unitary operators from their experiment to `unitary_dict`, uncomment the block above. When `unitaries.create_dict()` is called, it will contain the identity and the $H$ and $K$ gates by default under the keys "Z", "X" and "Y", respectively.

The number of visible units in the RBM is equal to the number of qubits. The number of hidden units will also be taken to be the number of visible units.

```python
nv = train_samples.shape[-1]
nh = nv

nn_state = ComplexWaveFunction(
    num_visible=nv, num_hidden=nh, unitary_dict=unitary_dict, gpu=False
)
```

By default, QuCumber will attempt to run on a GPU if one is available (if one is not available, QuCumber will fall back to CPU). If one wishes to guarantee that QuCumber runs on the CPU, add the flag `gpu=False` in the `ComplexWaveFunction` object instantiation. Set `gpu=True` in the line above to run this tutorial on a GPU.

Now the hyperparameters of the training process can be specified.

1. `epochs`: the total number of training cycles that will be performed (default = 100)
2. `pos_batch_size`: the number of data points used in the positive phase of the gradient (default = 100)
3. `neg_batch_size`: the number of data points used in the negative phase of the gradient (default = `pos_batch_size`)
4. `k`: the number of contrastive divergence steps (default = 1)
5. `lr`: the learning rate (default = 0.001)

    **Note:** For more information on the hyperparameters above, it is strongly encouraged that the user to read through the brief, but thorough theory document on RBMs. One does not have to specify these hyperparameters, as their default values will be used without the user overwriting them. It is recommended to keep with the default values until the user has a stronger grasp on what these hyperparameters mean. The quality and the computational efficiency of the training will highly depend on the choice of hyperparameters. As such, playing around with the hyperparameters is almost always necessary.

The two-qubit example in this tutorial should be extremely easy to train, regardless of the choice of hyperparameters. However, the hyperparameters below will be used.

```python
epochs = 100
pbs = 50  # pos_batch_size
nbs = 50  # neg_batch_size
lr = 0.1
k = 5
```

For evaluating the training in real time, the `MetricEvaluator` will be called to calculate the training evaluators every 10 epochs. The `MetricEvaluator` requires the following arguments.

1. `period`: the frequency of the training evaluators being calculated (e.g. `period=200` means that the `MetricEvaluator` will compute the desired metrics every 200 epochs)
2. A dictionary of functions you would like to reference to evaluate the training (arguments required for these functions are keyword arguments placed after the dictionary)

The following additional arguments are needed to calculate the fidelity and KL divergence in the `training_statistics` utility.

- `target_psi` (the true wavefunction of the system)
- `space` (the entire Hilbert space of the system)

The training evaluators can be printed out via the `verbose=True` statement.

Although the fidelity and KL divergence are excellent training evaluators, they are not practical to calculate in most cases; the user may not have access to the target wavefunction of the system, nor may generating the Hilbert space of the system be computationally feasible. However, evaluating the training in real time is extremely convenient.

Any custom function that the user would like to use to evaluate the training can be given to the `MetricEvaluator`, thus avoiding having to calculate fidelity and/or KL divergence. As an example, functions that calculate the the norm of each of the reconstructed wavefunction's coefficients are presented. Any custom function given to `MetricEvaluator` must take the neural-network state (in this case, the `ComplexWaveFunction` object) and keyword arguments. Although the given example requires the Hilbert space to be computed, the scope of the `MetricEvaluator`'s ability to be able to handle any function should still be evident.

```python
def alpha(nn_state, space, **kwargs):
    rbm_psi = nn_state.psi(space)
    normalization = nn_state.compute_normalization(space).sqrt_()
    alpha_ = cplx.norm(
        torch.tensor([rbm_psi[0][0], rbm_psi[1][0]], device=nn_state.device)
        / normalization
    )

    return alpha_

def beta(nn_state, space, **kwargs):
    rbm_psi = nn_state.psi(space)
    normalization = nn_state.compute_normalization(space).sqrt_()
    beta_ = cplx.norm(
        torch.tensor([rbm_psi[0][1], rbm_psi[1][1]], device=nn_state.device)
        / normalization
    )

    return beta_

def gamma(nn_state, space, **kwargs):
    rbm_psi = nn_state.psi(space)
    normalization = nn_state.compute_normalization(space).sqrt_()
    gamma_ = cplx.norm(
        torch.tensor([rbm_psi[0][2], rbm_psi[1][2]], device=nn_state.device)
        / normalization
    )

    return gamma_

def delta(nn_state, space, **kwargs):
    rbm_psi = nn_state.psi(space)
    normalization = nn_state.compute_normalization(space).sqrt_()
    delta_ = cplx.norm(
        torch.tensor([rbm_psi[0][3], rbm_psi[1][3]], device=nn_state.device)
        / normalization
    )

    return delta_
```

Now the Hilbert space of the system must be generated for the fidelity and KL divergence and the dictionary of functions the user would like to compute every `period` epochs must be given to the `MetricEvaluator`. Note that some of the coefficients aren't being evaluated as they are commented out. This is simply to avoid cluttering the output, and may be uncommented by the user.

```python
period = 2
space = nn_state.generate_hilbert_space(nv)

callbacks = [
    MetricEvaluator(
        period,
        {
            "Fidelity": ts.fidelity,
            "KL": ts.KL,
            "normα": alpha,
            # "normβ": beta,
            # "normγ": gamma,
            # "normδ": delta,
        },
        target_psi=true_psi,
        bases=bases,
        verbose=True,
        space=space,
    )
]
```

Now the training can begin. The `ComplexWaveFunction` object has a function called `fit` which takes care of this.

```python
nn_state.fit(
    train_samples,
    epochs=epochs,
    pos_batch_size=pbs,
    neg_batch_size=nbs,
    lr=lr,
    k=k,
    input_bases=train_bases,
    callbacks=callbacks,
)
```

    Epoch: 2	Fidelity = 0.623747	KL = 0.226386	normα = 0.272518
    Epoch: 4	Fidelity = 0.744691	KL = 0.142639	normα = 0.248872
    Epoch: 6	Fidelity = 0.818254	KL = 0.094584	normα = 0.263589
    Epoch: 8	Fidelity = 0.867098	KL = 0.067506	normα = 0.278453
    Epoch: 10	Fidelity = 0.900217	KL = 0.051592	normα = 0.281094
    Epoch: 12	Fidelity = 0.922993	KL = 0.041311	normα = 0.276052
    Epoch: 14	Fidelity = 0.937807	KL = 0.034972	normα = 0.274676
    Epoch: 16	Fidelity = 0.947232	KL = 0.030543	normα = 0.283873
    Epoch: 18	Fidelity = 0.955277	KL = 0.027313	normα = 0.278906
    Epoch: 20	Fidelity = 0.959930	KL = 0.025034	normα = 0.290271
    Epoch: 22	Fidelity = 0.963333	KL = 0.023719	normα = 0.296183
    Epoch: 24	Fidelity = 0.969419	KL = 0.021086	normα = 0.276108
    Epoch: 26	Fidelity = 0.972300	KL = 0.020200	normα = 0.290305
    Epoch: 28	Fidelity = 0.974777	KL = 0.018635	normα = 0.284231
    Epoch: 30	Fidelity = 0.976208	KL = 0.017865	normα = 0.282036
    Epoch: 32	Fidelity = 0.978382	KL = 0.016862	normα = 0.282498
    Epoch: 34	Fidelity = 0.980578	KL = 0.015977	normα = 0.279435
    Epoch: 36	Fidelity = 0.980983	KL = 0.015545	normα = 0.277835
    Epoch: 38	Fidelity = 0.982651	KL = 0.014751	normα = 0.280070
    Epoch: 40	Fidelity = 0.983155	KL = 0.014353	normα = 0.276912
    Epoch: 42	Fidelity = 0.983996	KL = 0.013827	normα = 0.278844
    Epoch: 44	Fidelity = 0.982731	KL = 0.015100	normα = 0.305219
    Epoch: 46	Fidelity = 0.984791	KL = 0.013417	normα = 0.293674
    Epoch: 48	Fidelity = 0.985395	KL = 0.012845	normα = 0.280658
    Epoch: 50	Fidelity = 0.986767	KL = 0.012093	normα = 0.277599
    Epoch: 52	Fidelity = 0.987795	KL = 0.011650	normα = 0.278886
    Epoch: 54	Fidelity = 0.987057	KL = 0.011843	normα = 0.271735
    Epoch: 56	Fidelity = 0.987125	KL = 0.011552	normα = 0.280304
    Epoch: 58	Fidelity = 0.987295	KL = 0.011382	normα = 0.288229
    Epoch: 60	Fidelity = 0.988201	KL = 0.011201	normα = 0.266736
    Epoch: 62	Fidelity = 0.989181	KL = 0.010504	normα = 0.288520
    Epoch: 64	Fidelity = 0.989308	KL = 0.010293	normα = 0.292218
    Epoch: 66	Fidelity = 0.989321	KL = 0.009901	normα = 0.282069
    Epoch: 68	Fidelity = 0.989347	KL = 0.009836	normα = 0.275723
    Epoch: 70	Fidelity = 0.989494	KL = 0.009838	normα = 0.293840
    Epoch: 72	Fidelity = 0.990115	KL = 0.009225	normα = 0.282556
    Epoch: 74	Fidelity = 0.990199	KL = 0.009095	normα = 0.278911
    Epoch: 76	Fidelity = 0.989979	KL = 0.009214	normα = 0.273241
    Epoch: 78	Fidelity = 0.989633	KL = 0.009275	normα = 0.274384
    Epoch: 80	Fidelity = 0.989972	KL = 0.008976	normα = 0.275430
    Epoch: 82	Fidelity = 0.989920	KL = 0.008871	normα = 0.285605
    Epoch: 84	Fidelity = 0.991177	KL = 0.008183	normα = 0.282607
    Epoch: 86	Fidelity = 0.991249	KL = 0.008095	normα = 0.276934
    Epoch: 88	Fidelity = 0.990857	KL = 0.008273	normα = 0.272151
    Epoch: 90	Fidelity = 0.990802	KL = 0.008071	normα = 0.280823
    Epoch: 92	Fidelity = 0.991090	KL = 0.007838	normα = 0.279963
    Epoch: 94	Fidelity = 0.990995	KL = 0.007861	normα = 0.275772
    Epoch: 96	Fidelity = 0.990326	KL = 0.008202	normα = 0.289882
    Epoch: 98	Fidelity = 0.991012	KL = 0.007690	normα = 0.277037
    Epoch: 100	Fidelity = 0.991736	KL = 0.007292	normα = 0.275516

All of these training evaluators can be accessed after the training has completed, as well. The code below shows this, along with plots of each training evaluator versus the training cycle number (epoch).

```python
# Note that the key given to the *MetricEvaluator* must be
# what comes after callbacks[0].
fidelities = callbacks[0].Fidelity

# Alternatively, we may use the usual dictionary/list subscripting
# syntax. This is useful in cases where the name of the metric
# may contain special characters or spaces.
KLs = callbacks[0]["KL"]
coeffs = callbacks[0]["normα"]
epoch = np.arange(period, epochs + 1, period)
```

```python
# Some parameters to make the plots look nice
params = {
    "text.usetex": True,
    "font.family": "serif",
    "legend.fontsize": 14,
    "figure.figsize": (10, 3),
    "axes.labelsize": 16,
    "xtick.labelsize": 14,
    "ytick.labelsize": 14,
    "lines.linewidth": 2,
    "lines.markeredgewidth": 0.8,
    "lines.markersize": 5,
    "lines.marker": "o",
    "patch.edgecolor": "black",
}
plt.rcParams.update(params)
plt.style.use("seaborn-deep")
```

```python
fig, axs = plt.subplots(nrows=1, ncols=3, figsize=(14, 3))
ax = axs[0]
ax.plot(epoch, fidelities, "o", color="C0", markeredgecolor="black")
ax.set_ylabel(r"Fidelity")
ax.set_xlabel(r"Epoch")

ax = axs[1]
ax.plot(epoch, KLs, "o", color="C1", markeredgecolor="black")
ax.set_ylabel(r"KL Divergence")
ax.set_xlabel(r"Epoch")

ax = axs[2]
ax.plot(epoch, coeffs, "o", color="C2", markeredgecolor="black")
ax.set_ylabel(r"$\vert\alpha\vert$")
ax.set_xlabel(r"Epoch")

plt.tight_layout()
plt.savefig("complex_fid_KL.pdf")
plt.show()
```

It should be noted that one could have just ran `nn_state.fit(train_samples)` and just used the default hyperparameters and no training evaluators.

At the end of the training process, the network parameters (the weights, visible biases and hidden biases) are stored in the `ComplexWaveFunction` object. One can save them to a pickle file, which will be called `saved_params.pt`, with the following command.

```python
nn_state.save("saved_params.pt")
```

This saves the weights, visible biases and hidden biases as torch tensors with the following keys: "weights", "visible_bias", "hidden_bias".


## Deep Learning: Classifying Astronomical Images

### January 6, 2020

[astroML workshop at the 235th Meeting of the American Astronomical Society](http://www.astroml.org/workshops/AAS235.html)

[Andrew Connolly, University of Washington](http://faculty.washington.edu/ajc26/)

Thanks to Hayden Smotherman, University of Washington for the example networks.

[Tutorial Repository](https://github.com/astroML/astroML-workshop_AAS235)

In this notebook we work through a simple example for a Neural Network using TensorFlow and the Keras interface. Initially we will start with a vanila network with two hidden layers and then expand this to a convolutional neural network with drop out layers and batch normalization

Deep learning, an extension of the neural networks that were
popularized in the 1990s. The concepts are inspired
by the structure and function of the brain. A neuron in the brain is a
core computational unit that takes a series of inputs from branched
extensions of the neuron called dendrites, operates on these inputs,
and generates an output that is transmitted along an axon to one or
more other neurons. In the context of a neural network a neuron, $j$, takes a set of inputs,
$x_i$, applies a, typically non-linear, function to these inputs and
generates an output value. Networks are then created by connecting
multiple neurons or layers of neurons to one another.

If we consider the simplified network
inputs are passed to the neurons in the
network. Each input is weighted by a value, $w_{ij}$ and the sum of
these weighted inputs is operated on by a response or activation
function $f(\theta)$, which transform
the input signal so that it varies between 0 and 1 through the
application of a non-linear response. The output from any neuron is
then given by,

$$
\begin {equation}
a_j =  f  \left( \sum_i w_{ij} x_i + b_j \right)
\end{equation}
$$

where $b_j$ is a bias term which determines the input level at which the
neuron becomes activated.

We refer to the neurons between the inputs and the output layers as
the hidden layers. If the neurons from one layer connect to all
neurons in a subsequent layer we call this a fully connected layer.
When the outputs from the neurons only connect to subsequent layers
(i.e. the graph is acyclic) we refer to this as a feed-forward
network -- this is the most common
structure for a neural network used in classification.

The final layer in the network is the output layer. As with the hidden
layer, an activation function, $g(\theta)$, in the output layer acts
on the weighted sum of its inputs.  In this figure we have a single
output node but there can be multiple outputs. For our example  network the
output from the final neuron, $y_k$, would be given by

$$
\begin {equation}
y_k = g \left( \sum_j   w_{jk} a_j  + b_k \right)  = g\left( \sum_j
  w_{jk}  f \left( \sum_i w_{ij} x_i + b_j\right) + b_k\right)
\end{equation}
$$

**Training of the network is simply the learning of the weights and bias values**

```python
# to reduce the deprecation warnings
import warnings
warnings.filterwarnings("ignore")
```

```python
#check we have a GPU available
from tensorflow.python.client import device_lib
device_lib.list_local_devices()
```

```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from astroML.utils import split_samples
%matplotlib inline
```

### Loading some visualization and helper functions

These functions  ```normalize_image```, ```plot_image_array```, ```plot_confusion_matrix```, ```plot_model_history``` will be used to visualize the data and the outputs of the neural networks as a function of the type and complexity of the network

```python
np.set_printoptions(precision=2)

def normalize_image(image):
    '''Rescale the constrast in an image based on the noise (used for displays and the CNN)'''
    sigmaG_coeff =  0.7413
    image = image.reshape(21,21)

    per25,per50,per75 = np.percentile(image,[25,50,75])
    sigmaG = sigmaG_coeff * (per75 - per25)
    # sigma clip image, remove background, and normalize to unity
    image[image<(per50-2*sigmaG)] = per50-2*sigmaG
    image -= np.min(image)
    image /= np.sum(image)

    return image

def plot_image_array(images, nrows=2, ncols=5, figsize=[8,4], nx=21, ny=21, title='', subtitle=False,
                     class_true=None, classes=None):
    '''Plot an array of images'''
    fig, ax = plt.subplots(nrows=nrows,ncols=ncols,figsize=figsize)
    fig.subplots_adjust(hspace=0, left=0.07, right=0.95, wspace=0.05, bottom=0.15)
    for indx in np.arange(nrows*ncols):
        i = int(indx/ncols)
        j = indx%ncols
        if (i == 0):
            ax[i][j].xaxis.set_major_formatter(plt.NullFormatter())
        if (j != 0):
            ax[i][j].yaxis.set_major_formatter(plt.NullFormatter())

        ax[i][j].imshow(images[indx].reshape(nx,ny), cmap='gray')
        if (subtitle == True):
            ax[i][j].set_title('True Class: %i, Predicted Class: %i\n  Prob Class 1 %e ' %
              (np.argmax(class_true[indx]), np.argmax(classes[indx]), classes[indx,1]))

    fig.suptitle(title)
    ax[0][0].set_ylabel('$y$')
    ax[nrows-1][int(ncols/2)].set_xlabel('$x$')

from sklearn.metrics import confusion_matrix
from sklearn.utils.multiclass import unique_labels

def plot_confusion_matrix(y_true, y_pred,
                          normalize=False,
                          title=None,
                          cmap=plt.cm.Blues):
    """
    From scikit-learn: plots a confusion matrix.
    Normalization can be applied by setting `normalize=True`.
    """
    if not title:
        if normalize:
            title = 'Normalized confusion matrix'
        else:
            title = 'Confusion matrix, without normalization'

    # Compute confusion matrix
    cm = confusion_matrix(y_true, y_pred)

    if normalize:
        cm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]

    fig, ax = plt.subplots()
    im = ax.imshow(cm, interpolation='nearest', cmap=cmap)
    ax.figure.colorbar(im, ax=ax)

    ax.set(xticks=np.arange(cm.shape[1]),
           yticks=np.arange(cm.shape[0]),
           # ... and label them with the respective list entries
           title=title,
           ylabel='True label',
           xlabel='Predicted label')

    # Rotate the tick labels and set their alignment.
    plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
             rotation_mode="anchor")

    #fixes "squishing of plot"
    plt.ylim([1.5, -.5])

    # Loop over data dimensions and create text annotations.
    fmt = '.2f' if normalize else 'd'
    thresh = cm.max() / 2.
    for i in range(cm.shape[0]):
        for j in range(cm.shape[1]):
            ax.text(j, i, format(cm[i, j], fmt),
                    ha="center", va="center",
                    color="white" if cm[i, j] > thresh else "black")
    fig.tight_layout()

def plot_model_history(history):
    '''Plot the training and validation history for a TensorFlow network'''

    # Extract loss and accuracy
    loss = history.history['loss']
    val_loss = history.history['val_loss']
    acc = history.history['accuracy']
    val_acc = history.history['val_accuracy']

    fig, ax = plt.subplots(nrows=1,ncols=2, figsize=(10,5))
    ax[0].plot(np.arange(n_epochs), loss, label='Training Loss')
    ax[0].plot(np.arange(n_epochs), val_loss, label='Validation Loss')
    ax[0].set_title('Loss Curves')
    ax[0].legend()
    ax[0].set_xlabel('Epoch')
    ax[0].set_ylabel('Loss')

    ax[1].plot(np.arange(n_epochs), acc, label='Training Accuracy')
    ax[1].plot(np.arange(n_epochs), val_acc, label='Validation Accuracy')
    ax[1].set_title('Accuracy Curves')
    ax[1].legend()
    ax[1].set_xlabel('Epoch')
    ax[1].set_ylabel('Accuracy')
```

### Load the training samples and create test and validation data sets

The data we are using is taken from a survey for NEOs by Lori Allen and collaborators using DECam on the Blanco 4m Telescope at CTIO. The data comprise a stack of images taken over a period of 5 nights. Within these images we search for slowly moving sources (TNOs) along potential orbital trajectories. Given these trajectories we coadd the images. Our goal is to determine whether there is a point source within the coadded images. The training sample includes images of simulated TNOs (true positives; stamps_sources.npz) and random trajectories where there is no known source (false positives; stamps_noise.npz). The true positives range in signal-to-noise from 100 to 3

```python
from google_drive_downloader import GoogleDriveDownloader as gd
gd.download_file_from_google_drive(file_id='1UT2BCf-IDUEpvTmcU4bq6nDcY3Ayw5vJ', dest_path='./data/stamps_noise.npy', unzip=False)
gd.download_file_from_google_drive(file_id='1cZaMCA0z_nPX6GB_meLGouwOidEROcwc', dest_path='./data/stamps_sources.npy', unzip=False)
```

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

#read in source and noise images
path = './data/'
sources = np.load(path+'stamps_sources.npy')
noise = np.load(path+'stamps_noise.npy')

point_source_stamps = []
for image in sources:
    point_source_stamps.append(normalize_image(image))

no_point_source_stamps = []
for image in noise:
    no_point_source_stamps.append(normalize_image(image))
```

```python
#plot sample of images
plot_image_array(no_point_source_stamps, title='false positives')
plot_image_array(point_source_stamps, title='true positives')
```

### Create a training, validation, and test sample

We will use astroML's split_samples to do this ```split_samples(input_stamps, stamp_class, [0.7,0.1,0.2])``` breaks the data in to random selections with  appropriate fractions of sources

```python
from tensorflow.keras.utils import to_categorical
def reshape_arrays(data, labels):
    '''reshape arrays for Keras'''
    data = data.reshape(-1,21, 21, 1)
    labels = to_categorical(labels)
    return data,labels

# combine the false positives and true positives
input_stamps = np.vstack([no_point_source_stamps, point_source_stamps])
stamp_class = np.zeros(len(no_point_source_stamps) + len(point_source_stamps))
stamp_class[len(no_point_source_stamps):] = 1 # 0 for noise, 1 for a star

# split the samples into training, validation and test data sets
(data_train, data_val, data_test), (class_train, class_val, class_test) = split_samples(input_stamps, stamp_class,
                                                                                [0.7,0.1,0.2])
data_train, class_train = reshape_arrays(data_train, class_train)
data_val, class_val = reshape_arrays(data_val, class_val)
data_test, class_test = reshape_arrays(data_test, class_test)

print ('Number of samples in the training ({}); test ({}); and validation ({}) data sets'.format(data_train.shape[0],
                                                                                data_test.shape[0],
                                                                                data_val.shape[0]))
```

## Neural Network Frameworks

The development and release of open source deep learning libraries has
made the use of deep neural networks accessible to a wide range of
fields. Currently there are two common packages PyTorch (https://pytorch.org) and Tensorflow (https://www.tensorflow.org). Either code base can be utilized for the
figures and problems in this book (and generally they have the same
functionality).

### TensorFlow:

Tensorflow is the more established code base with a large community
and a large number of tutorials (https://www.tensorflow.org/tutorials) and online courses. Its
functionality is more developed than PyTorch with tools to visualize
and inspect a network (e.g., see TensorBoard). On the other hand, the
learning curve for PyTorch is generally considered to be easier than
that for Tensorflow with PyTorch having a more natural object oriented
interface for people used to writing Python code.

### PyTorch:

The primary difference between TensorFlow and PyTorch is that the
networks (or graphs) that TensorFlow generates are static while the
networks for PyTorch are dynamic (see TensorFlow Fold for dynamic graphs). This means that with PyTorch one can
modify and adjust the network on-the-fly (e.g., making it easier to
adjust for changes in the input dimensionality or number of input
nodes within a network). This feature and the object-oriented design of
PyTorch often results in fewer lines of code to achieve the same
solution when compared to Tensorflow.

### Keras:

Keras is a high-level API written on top of TensorFlow (and its precursor Theano). It is written in Python and provides a simple and intuitive interface when building neural networks. It is currently released as part of TensorFlow.

**What should you choose?** Both frameworks are continuously evolving.
The choice of deep learning library will
likely come down to which one you find better fits your style of
programming and learning. For this tutorial we will use Keras as it has an intuitive implementation of the graphical or network models.

### Building a network:

Let's start by defining what we need for the network. We will start with Keras and

- create a sequential model (this means we add layers one-by-one as we see in our introductory figure)
- add a dense (fully connected) layer with 30 neurons
  - **input_shape** describes the dimensionality of the _input data_ to this first hidden layer
  - **activation** describes the activation fuction for the neurons (in this case we will be using 'relu'; rectified linear unit)
- add a second dense (fully connected) layer with 30 neurons
- flatten the output of the second layer into a single vector so we can use ```categorical_crossentropy``` as we are assuming that our classes are "one-hot encoding" (i.e. [1,0] or [0,1]
- add an output layer using "softmax" (this means the activation values for each class sum to 1 so they can be treated like probabilities) with 2 nodes (_for our example we could have used a single output_)

### Training the network
Training a neural network is conceptually simple. Given a labelled set
of data and a loss function, we need to optimize
the weights and biases within the network by minimizing the loss.  A solution for training large
networks
uses backpropagation to efficiently estimate the gradient of the loss
function with respect
to the weights and biases.

## Creating a Neural Network with Keras

```python
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Conv2D, Flatten, Activation
from tensorflow.keras.utils import to_categorical
import matplotlib.pyplot as plt

import numpy as np
def simple(input_shape=(21, 21, 1), n_classes: int = 2):

    model = tf.keras.models.Sequential(name='simple')

    # input: 21x21 images with 1 channel -> (21, 21, 1) tensors.
    model.add(tf.keras.layers.Dense(30, input_shape=input_shape, activation='relu', name='fc_1'))

    model.add(tf.keras.layers.Dense(30, activation='relu', name='fc_2'))

    # output layer
    model.add(tf.keras.layers.Flatten())
    activation = 'sigmoid' if n_classes == 1 else 'softmax'
    model.add(tf.keras.layers.Dense(n_classes, activation=activation, name='fc_out'))

    return model
```

```python
#output the structure of the model
simple_model = simple()
simple_model.summary()
```

```python
n_epochs=1
batch_size=1
simple_model.compile(loss='categorical_crossentropy', optimizer='sgd', metrics=['accuracy'])
simple_model_history = simple_model.fit(data_train, class_train, epochs=n_epochs, batch_size=batch_size,  verbose=1,
                                        validation_data=(data_val, class_val), shuffle=True)
```

**Mini-batch:** Optimization of the weights uses a standard gradient descent technique. If the loss function can be expressed in terms of a sum over subsets of the training data (e.g., as is the case for the
L2 norm) the training can be undertaken either for the dataset as a
whole, for subsets of the data (batch learning), or for individual
entries (on-line or stochastic learning). _Batch gradient descent_ looks at all points in the data and calculates the average gradients before updating the weights in the model. _Stochastic gradient descent_ takes a single point and calculates the gradients and then updates the model (and then repeats). _Mini-batch gradient descent_ takes a subset of the training data and  calculates the average gradients and  updates the model  (and then repeats over all mini-batches).

```python
n_epochs=20
batch_size=
simple_model.compile(loss='categorical_crossentropy', optimizer='sgd', metrics=['accuracy'])
simple_model_history = simple_model.fit(data_train, class_train, epochs=n_epochs, batch_size=batch_size,  verbose=1,
                                        validation_data=(data_val, class_val), shuffle=True)
```

### Performance of the network

```python
# use the network to predict class values
classes = simple_model.predict(data_test)
print (classes)
```

```python
# plot the confusion matrix
plot_confusion_matrix(np.argmax(class_test,axis=1), np.argmax(classes,axis=1), normalize=True,
                      title='Normalized confusion matrix')
```

```python
# plot the training history of the network
plot_model_history(simple_model_history)
```

### Batch normalization

Our first optimization over the vanila NN. Batch normalization scales the activations from a layer (note we normalized the input data) to have zero mean and unit variance. In reality, the two parameters gamma (for the standard deviation) and beta (for the mean) are learned by the network and the activations multiplied/added by these parameters. Batch normalization provides a degree of regularization and allows for faster learning rates as the outputs are constrained to 0-1 (i.e. you dont get large excursions in the weights of subsequent layers in a network that need to be reoptimized/trained).

The normalization is applied to mini-batches of training data (as opposed to using the full training sample)
- add a batch normalization layer:  ```model.add(tf.keras.layers.BatchNormalization(axis = 3, name = 'bn_1'))```

```python
def simpleBN(input_shape=(21, 21, 1), n_classes: int = 2):

    model = tf.keras.models.Sequential(name='simple')

    # input: 21x21 images with 1 channel -> (21, 21, 1) tensors.
    model.add(tf.keras.layers.Dense(30, input_shape=input_shape, activation='relu', name='fc_1'))

    model.add(tf.keras.layers.Dense(30, activation='relu', name='fc_2'))

    # output layer
    model.add(tf.keras.layers.Flatten())
    activation = 'sigmoid' if n_classes == 1 else 'softmax'
    model.add(tf.keras.layers.Dense(n_classes, activation=activation, name='fc_out'))

    return model
```

```python
n_epochs=20
simple_model = simpleBN()
simple_model.compile(loss='categorical_crossentropy', optimizer='sgd', metrics=['accuracy'])
simple_model_history = simple_model.fit(data_train, class_train, epochs=n_epochs, batch_size=256, verbose=1,
                                        validation_data=(data_val, class_val), shuffle=True)
classes = simple_model.predict(data_test)
```

```python
# Plot normalized confusion matrix
plot_confusion_matrix(np.argmax(class_test,axis=1), np.argmax(classes,axis=1), normalize=True,
                      title='Normalized confusion matrix')
```

```python
plot_model_history(simple_model_history)
```

### Convolutional Networks

Convolutional Neural Networks or
CNNs are networks designed to work with images or with any regularly
sampled dataset. CNNs reduce the complexity of the network by
requiring that neurons only respond to inputs from a subset of an
image (the receptive field). This mimics the operation of the visual
cortex where neurons only respond to a small part of the
field-of-view.

There are four principal components to a CNN:
- a convolutional layer,
- a _non-linear activation function_ ,
- a pooling or downsampling operation, and
- a _fully connected layer for classification_

Dependent on the complexity of the network or structure of the data,
these components can occur singularly or chained together in multiple
sequences.

**Convolution** in a CNN refers to the convolution  of the input data
$I(x,y)$ with a kernel $K(x,y)$ which will produce a feature map $F(x,y)$

\begin{equation}
F(x,y) = K(x,y) * I(x,y)  = \sum_{x_0} \sum_{y_0} I(x-x_0, y-y_0) K(x_0, y_0).
\end{equation}

The kernel only responds to pixels within its receptive field (i.e.,
the size of the kernel), reducing the computational complexity of the
resulting network. The kernels in the convolution are described by a
depth (the number of kernels, $K$, applied to the image), and a stride
(how many pixels a kernel shifts at each step in the convolution;
typically one).  Given an $N\times M$ image, the result of the
convolution step is to transform a single image into a data cube of
feature maps with a dimension $N \times M \times K$.

Once **learned** the kernels within the convolutional layer can appear
as physically intuitive operations on the images
such as edge detection filters.

As with traditional neural networks, a non-linear activation function
is applied to the individual pixels in the resulting feature
maps.

The **pooling** in the CNN downsamples or subsamples the feature maps. Pooling summarizes values within a region
of interest (e.g., a 2x2 pixel window). The summary can be the average
pixel value but more commonly the maximum pixel value is preserved
(Max Pooling) in the downsampling. This pooling of the feature maps
reduces the size of the resulting network and makes the network less
sensitive to small translations or distortions between images.

The final layer of a CNN is the classification layer which maps the
output of the CNN to a set of labels. This is typically a fully
connected layer where each output
of the final pooling layer connects to all neurons in the
classification layer.

### CNNs: Increasing the complexity of the architecture with VGG6

Let's start with a simple network architecture from Oxford's Visual Geometry Group. VGG6 is a very simple network that performs well in traditional image classification competitions (e.g. those using the ImageNet data)

```python
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Conv2D, Flatten
from tensorflow.keras.utils import to_categorical
import matplotlib.pyplot as plt

import numpy as np
def vgg6(input_shape=(21, 21, 1), n_classes: int = 2):
    """
        VGG6
    :param input_shape:
    :param n_classes:
    :return:
    """

    model = tf.keras.models.Sequential(name='VGG6')
    # input: 21x21 images with 1 channel -> (21, 21, 1) tensors.
    # this applies 16 convolution filters of size 3x3 each.
    model.add(tf.keras.layers.Conv2D(16, (3, 3), activation='relu', input_shape=input_shape, name='conv1'))
    model.add(tf.keras.layers.Conv2D(16, (3, 3), activation='relu', name='conv2'))
    model.add(tf.keras.layers.MaxPooling2D(pool_size=(2, 2)))
    model.add(tf.keras.layers.Dropout(0.25))

    model.add(tf.keras.layers.Conv2D(32, (3, 3), activation='relu', name='conv3'))
    model.add(tf.keras.layers.Conv2D(32, (3, 3), activation='relu', name='conv4'))
    model.add(tf.keras.layers.BatchNormalization(axis = 3, name = 'bn_2'))
    model.add(tf.keras.layers.MaxPooling2D(pool_size=(4, 4)))
    model.add(tf.keras.layers.Dropout(0.25))

    model.add(tf.keras.layers.Flatten())

    model.add(tf.keras.layers.Dense(256, activation='relu', name='fc_1'))
    model.add(tf.keras.layers.Dropout(0.5))
    # output layer
    activation = 'sigmoid' if n_classes == 1 else 'softmax'
    model.add(tf.keras.layers.Dense(n_classes, activation=activation, name='fc_out'))

    return model
```

### The use of dropout layers

As we increase the complexity of the network we run into the issue of overfitting the data (as seen in many of the astroML examples). The dropout layer ```model.add(tf.keras.layers.Dropout(0.5))``` at each training epoch randomly sets a neuron to 0 with a probability of 0.5. There is debate over whether the dropout layer should come before or after an activation layer but a recommended rule of thumb is that it should come after the activation layer for activation functions other than relu.

```python
vgg_model = vgg6()
vgg_model.compile(loss='categorical_crossentropy', optimizer='sgd', metrics=['accuracy'])
n_epochs=15
vgg_model.summary()
vgg_model_history = vgg_model.fit(data_train, class_train, epochs=n_epochs, batch_size=1024, verbose=2,
                                  validation_data=(data_val, class_val), shuffle=True)
classes = vgg_model.predict(data_test)
```

```python
# plot the confusion matrix
plot_confusion_matrix(np.argmax(class_test,axis=1), np.argmax(classes,axis=1), normalize=True,
                      title='Normalized confusion matrix')
```

```python
# plot the training history
plot_model_history(vgg_model.history)
```

```python
# plot example classifications
plot_image_array(data_val, figsize=[16,10], subtitle=True, classes=classes, class_true=class_test)
```

### Interpreting networks: how many layers and how many neurons?

The number of layers, number of neurons in a layer, and the
connectivity of these layers is typically described as the network
architecture.

Approaches to defining a network
architecture become more trial and error than applying an underlying
set of principles. For a starting point, however, there are relatively
few problems that benefit significantly from more than two layers and
we recommend starting with a single layer when training an initial
network and using cross-validation to determine when additional layers
lead result in the data being overfit.

As with the number of layers, the number of neurons within a layer
drives the computational cost (and requiring progressively larger
training sets to avoid overfitting of the data). There are many
proposals for rules of thumb for defining a network architecture:
- the number of neurons should lie between the number of inputs and output nodes
- the number of neurons should be equal to the number of outputs plus 2/3rd the number input nodes.
- the number of neurons in the hidden layer should be less than twice the size of the input layers

### Interpreting networks: where is a network looking

Occulsion maps, saliency maps, class activation maps are all techniques for expressing which pixels contribute to classification. These are attempts to reduce the "black box" nature of the networks. The simplest of these is the occlussion map where we part of an image and calculate the probability of it belonging to a class. If the probability decreases the occluded part of the image is assumed to be important. If there is no change in probability the occluded pixels are not assumed to be important. A simple implementation of this is shown here.

```python
model = vgg_model
image_number = 11

kernel_size=5
input_stamp = data_test[image_number].reshape(21,21)
i = 0
j=0
heatmap = []
keras_stamps = []
for j in range(22-kernel_size):
    for i in range(22-kernel_size):
        img = np.copy(input_stamp)
        img[i:i+kernel_size,j:j+kernel_size] = 0
        img = normalize_image(img)
        keras_stamps.append(img)
keras_stamps = np.array(keras_stamps).reshape([-1,21,21,1])
probs = 1. - model.predict(keras_stamps)
heatmap = probs[:,1].reshape(22-kernel_size,22-kernel_size)

def transparent_cmap(cmap, N=255):
    "Copy colormap and set alpha values"
    mycmap = cmap
    mycmap._init()
    mycmap._lut[:,-1] = np.linspace(0, 0.8, N+4)
    return mycmap

# pad heatmap to same size as original image
heatmap = np.pad(heatmap, pad_width=np.int(kernel_size/2), mode='minimum')

# use the base cmap to create transparent overlay
mycmap = transparent_cmap(plt.cm.Reds)
fig, ax = plt.subplots(nrows=1,ncols=1)
ax.imshow(data_test[image_number].reshape(21,21), cmap='gray')
ax.imshow(np.array(heatmap), alpha=0.5, cmap=mycmap)

```

### Exercise for the reader: more complicated architectures: resnet50

```python
# Based on https://github.com/priya-dwivedi/Deep-Learning/blob/master/resnet_keras/Residual_Networks_yourself.ipynb
import numpy as np
import tensorflow as tf
from tensorflow.keras.layers import Input, Add, Dense, Activation, ZeroPadding2D, BatchNormalization, Flatten, Conv2D, AveragePooling2D, MaxPooling2D, GlobalMaxPooling2D
from tensorflow.keras.initializers import glorot_uniform
from tensorflow.keras.models import Model, load_model
from tensorflow.keras.utils import to_categorical
import tensorflow.keras.backend as K
K.set_image_data_format('channels_last')
def identity_block(X, f, filters, stage, block):
    """
    Implementation of the identity block as defined in Figure 3

    Arguments:
    X -- input tensor of shape (m, n_H_prev, n_W_prev, n_C_prev)
    f -- integer, specifying the shape of the middle CONV's window for the main path
    filters -- python list of integers, defining the number of filters in the CONV layers of the main path
    stage -- integer, used to name the layers, depending on their position in the network
    block -- string/character, used to name the layers, depending on their position in the network

    Returns:
    X -- output of the identity block, tensor of shape (n_H, n_W, n_C)
    """

    # defining name basis
    conv_name_base = 'res' + str(stage) + block + '_branch'
    bn_name_base = 'bn' + str(stage) + block + '_branch'

    # Retrieve Filters
    F1, F2, F3 = filters

    # Save the input value. You'll need this later to add back to the main path.
    X_shortcut = X

    # First component of main path
    X = Conv2D(filters = F1, kernel_size = (1, 1), strides = (1,1), padding = 'valid', name = conv_name_base + '2a', kernel_initializer = glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis = 3, name = bn_name_base + '2a')(X)
    X = Activation('relu')(X)

    # Second component of main path (≈3 lines)
    X = Conv2D(filters = F2, kernel_size = (f, f), strides = (1,1), padding = 'same', name = conv_name_base + '2b', kernel_initializer = glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis = 3, name = bn_name_base + '2b')(X)
    X = Activation('relu')(X)

    # Third component of main path (≈2 lines)
    X = Conv2D(filters = F3, kernel_size = (1, 1), strides = (1,1), padding = 'valid', name = conv_name_base + '2c', kernel_initializer = glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis = 3, name = bn_name_base + '2c')(X)

    # Final step: Add shortcut value to main path, and pass it through a RELU activation (≈2 lines)
    X = Add()([X, X_shortcut])
    X = Activation('relu')(X)

    return X

def convolutional_block(X, f, filters, stage, block, s = 2):
    """
    Implementation of the convolutional block as defined in Figure 4

    Arguments:
    X -- input tensor of shape (m, n_H_prev, n_W_prev, n_C_prev)
    f -- integer, specifying the shape of the middle CONV's window for the main path
    filters -- python list of integers, defining the number of filters in the CONV layers of the main path
    stage -- integer, used to name the layers, depending on their position in the network
    block -- string/character, used to name the layers, depending on their position in the network
    s -- Integer, specifying the stride to be used

    Returns:
    X -- output of the convolutional block, tensor of shape (n_H, n_W, n_C)
    """

    # defining name basis
    conv_name_base = 'res' + str(stage) + block + '_branch'
    bn_name_base = 'bn' + str(stage) + block + '_branch'

    # Retrieve Filters
    F1, F2, F3 = filters

    # Save the input value
    X_shortcut = X

    ##### MAIN PATH #####
    # First component of main path
    X = Conv2D(F1, (1, 1), strides = (s,s), name = conv_name_base + '2a', kernel_initializer = glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis = 3, name = bn_name_base + '2a')(X)
    X = Activation('relu')(X)

    # Second component of main path (≈3 lines)
    X = Conv2D(filters = F2, kernel_size = (f, f), strides = (1,1), padding = 'same', name = conv_name_base + '2b', kernel_initializer = glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis = 3, name = bn_name_base + '2b')(X)
    X = Activation('relu')(X)

    # Third component of main path (≈2 lines)
    X = Conv2D(filters = F3, kernel_size = (1, 1), strides = (1,1), padding = 'valid', name = conv_name_base + '2c', kernel_initializer = glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis = 3, name = bn_name_base + '2c')(X)

    ##### SHORTCUT PATH #### (≈2 lines)
    X_shortcut = Conv2D(filters = F3, kernel_size = (1, 1), strides = (s,s), padding = 'valid', name = conv_name_base + '1',
                        kernel_initializer = glorot_uniform(seed=0))(X_shortcut)
    X_shortcut = BatchNormalization(axis = 3, name = bn_name_base + '1')(X_shortcut)

    # Final step: Add shortcut value to main path, and pass it through a RELU activation (≈2 lines)
    X = Add()([X, X_shortcut])
    X = Activation('relu')(X)

    return X

def ResNet50(input_shape=(21, 21, 1), classes=2):
    """
    Implementation of the popular ResNet50 the following architecture:
    CONV2D -> BATCHNORM -> RELU -> MAXPOOL -> CONVBLOCK -> IDBLOCK*2 -> CONVBLOCK -> IDBLOCK*3
    -> CONVBLOCK -> IDBLOCK*5 -> CONVBLOCK -> IDBLOCK*2 -> AVGPOOL -> TOPLAYER

    Arguments:
    input_shape -- shape of the images of the dataset
    classes -- integer, number of classes

    Returns:
    model -- a Model() instance in Keras
    """

    # Define the input as a tensor with shape input_shape
    X_input = Input(input_shape)

    # Zero-Padding
    X = ZeroPadding2D((3, 3))(X_input)

    # Stage 1
    X = Conv2D(64, (7, 7), strides=(2, 2), name='conv1', kernel_initializer=glorot_uniform(seed=0))(X)
    X = BatchNormalization(axis=3, name='bn_conv1')(X)
    X = Activation('relu')(X)
    X = MaxPooling2D((3, 3), strides=(2, 2))(X)

    # Stage 2
    X = convolutional_block(X, f=3, filters=[64, 64, 256], stage=2, block='a', s=1)
    X = identity_block(X, 3, [64, 64, 256], stage=2, block='b')
    X = identity_block(X, 3, [64, 64, 256], stage=2, block='c')

    ### START CODE HERE ###

    # Stage 3 (≈4 lines)
    X = convolutional_block(X, f = 3, filters = [128, 128, 512], stage = 3, block='a', s = 2)
    X = identity_block(X, 3, [128, 128, 512], stage=3, block='b')
    X = identity_block(X, 3, [128, 128, 512], stage=3, block='c')
    X = identity_block(X, 3, [128, 128, 512], stage=3, block='d')

    # Stage 4 (≈6 lines)
    X = convolutional_block(X, f = 3, filters = [256, 256, 1024], stage = 4, block='a', s = 2)
    X = identity_block(X, 3, [256, 256, 1024], stage=4, block='b')
    X = identity_block(X, 3, [256, 256, 1024], stage=4, block='c')
    X = identity_block(X, 3, [256, 256, 1024], stage=4, block='d')
    X = identity_block(X, 3, [256, 256, 1024], stage=4, block='e')
    X = identity_block(X, 3, [256, 256, 1024], stage=4, block='f')

    # Stage 5 (≈3 lines)
    X = convolutional_block(X, f = 3, filters = [512, 512, 2048], stage = 5, block='a', s = 2)
    X = identity_block(X, 3, [512, 512, 2048], stage=5, block='b')
    X = identity_block(X, 3, [512, 512, 2048], stage=5, block='c')

    # AVGPOOL (≈1 line). Use "X = AveragePooling2D(...)(X)"
    #X = AveragePooling2D((2,2), name="avg_pool")(X)

    ### END CODE HERE ###

    # output layer
    X = Flatten()(X)
    X = Dense(classes, activation='softmax', name='fc' + str(classes), kernel_initializer = glorot_uniform(seed=0))(X)

    # Create model
    model = Model(inputs = X_input, outputs = X, name='ResNet50')

    return model
```

```python
resnet50_model = ResNet50()
resnet50_model.compile(loss='categorical_crossentropy', optimizer='sgd', metrics=['accuracy'])
n_epochs=5
resnet_model_history = resnet50_model.fit(data_train, class_train, epochs=n_epochs, batch_size=256, verbose=1,
                                          validation_data=(data_val, class_val), shuffle=True)
```


# Projeto I - Modelagem Matemática em Finanças I - 2019
> Grupo: Gil Miranda e João Victor Fonseca<br>

```python
import numpy as np
import matplotlib.pyplot as plt
from sympy import binomial as b
```

```python
## Função para simular os valores da ação dados os parametros
def binomial(S0, T, dt, u, d, p):
    Si = S0
    S = []
    t = np.arange(0,T,dt)
    for ti in t:
        rnd = np.random.rand()
        if rnd < p:
            Si *= u
        else:
            Si *= d
        S.append(Si)
    return S
```

```python
## Definição dos parametros
T = 20
dt = 0.5
S0 = 5
u = 120/110
d = 110/120
p = 0.5

## Construção da lista de valores da ação pelo tempo
x = [binomial(S0, T, dt, u, d, p) for i in range(0,20)]

## Inserir o valor inicial em cada lista
for i in range(0,20):
    x[i].insert(0,S0)

## Discretização do tempo
ts = np.linspace(0,20,41)

## Plot do gráfico
plt.figure(figsize=(20,10))
for i in range(0,20):
    plt.plot(ts,x[i],label="{0:.5f}".format(x[i][-1]))
plt.title('Simulação com 20 caminhos de valor da ação')
plt.legend(title='Valor final', loc='upper center', fancybox=True, shadow=True,
           ncol=7, bbox_to_anchor=(0.5,1.05))
plt.xlabel('Tempo')
plt.ylabel('Valor da Ação.')
plt.show()
```

Há um problema aqui, a maior parte dos caminhos de valor da ação estão convergindo para 0.
Portanto vamos analisar os gráficos na escala log em Y.
Identificarei cada curva com seu valor final

```python
plt.figure(figsize=(20,10))
for i in range(0,20):
    plt.semilogy(ts,x[i],label="{0:.5f}".format(x[i][-1]))
plt.legend(title='Valor final', loc='upper center', fancybox=True, shadow=True, ncol=7, bbox_to_anchor=(0.5,1.05))
plt.title('Caminhos de valor da ação')
plt.show()
```

## Boxplot

```python
vs = [[],[]]
for j in [250,500,1000,2000]:
    vs[0].append([binomial(S0, T, dt, u, d, p)[-1] for i in range(0,j)])
plt.boxplot(vs[0])
plt.title('Boxplot com simulações')
plt.xlabel('Número de caminhos executados na simulação')
plt.xticks([1,2,3,4],[250,500,1000,2000])
plt.show()
```

```python
(1/2 * 120/110 + 1/2 * 110/120)**20
```

    1.0785466676518478

## Modificando os parâmetros

```python
u_n = np.sqrt(u)
d_n = np.sqrt(d)
dt_n = dt/2
for j in [250,500,1000,2000]:
    vs[1].append([binomial(S0, T, dt_n, u_n, d_n, p)[-1] for i in range(0,j)])

plt.boxplot(vs[1])

plt.title('Boxplot com simulações')
plt.ylabel('Valores das ações ao tempo final')
plt.xlabel('Número de caminhos executados na simulação')
plt.xticks([1,2,3,4],[250,500,1000,2000])
plt.show()
```

```python
np.mean(vs[1][0])
```

    5.489027709163725

```python
## Função para calcular a esperança
def esperanca(p_e,u_e,d_e,S0_e,n_e):
    return ((p_e*u_e+(1-p_e)*d_e)**n_e)*S0_e
```

```python
e = [0,0]
e[0] = esperanca(p,u,d,S0,T)
e[1] = esperanca(p,u_n,d_n,S0,T)
```

```python
ts = np.linspace(250,2000,4)

ys = [[],[]]
for j in [0,1]:
    for i in vs[j]:
        ys[j].append(abs((np.mean(i)-e[j])))
```

```python
plt.plot(ts,ys[0],label='Caso 1')
plt.plot(ts,ys[1], label='Caso 2')
plt.title('Gráfico do erro em relação ao número de simulações')
plt.xlabel('Número de simulações')
plt.ylabel('Erro relativo')
plt.legend()
plt.show()
```

```python
plt.semilogy(ts,ys[0],label='Caso 1')
plt.semilogy(ts,ys[1], label='Caso 2')
plt.legend()
plt.title('Gráfico do erro em relação ao número de simulações escala log')
plt.xlabel('Número de simulações')
plt.ylabel('Erro relativo')
plt.show()
```

```python

```

```python

```


## Group A12 submission : HW - 2

### Section 2 - Solution

```python
import pandas as pd
import numpy as np
import datetime as dt
import statsmodels.api as sm
import matplotlib.pyplot as plt
plt.style.use('ggplot')
import seaborn as sns
import statsmodels.formula.api as smf
import numpy
```

```python
path_to_data_file = r'proshares_analysis_data.xlsx'   # Assuming that the file is in the root/home directory

df_sp = pd.read_excel(path_to_data_file,sheet_name = 2)

df_sp.set_index('date',inplace =True)

S_and_P_data = df_sp['SPY US Equity']

df_hedge_fund = pd.read_excel(path_to_data_file, sheet_name = 1)

df_hedge_fund.set_index('date',inplace = True)

periods = 12
```

### Question 1

1.  For the series in the “hedgefundseries” tab, report the following summary statistics:
1(a)  mean
 (b)  volatility
 (c)  Sharpe ratioAnnualize these statistics.

```python
df_hfs = pd.read_excel('proshares_analysis_data.xlsx',sheet_name = 1).set_index('date')
```

```python
periods = 12
df_hfs_annualized = df_hfs*periods
```

```python
mean = df_hfs_annualized.mean()
std = df_hfs_annualized.std()/np.sqrt(periods)
sharpe = mean/std
df_summary = pd.DataFrame({'Mean(%)':mean*100,"Volatility":std,"Sharpe":sharpe})
```

```python
df_summary
```

      Mean(%)
      Volatility
      Sharpe

      HFRIFWI Index
      5.078422
      0.061507
      0.825665

      MLEIFCTR Index
      3.882076
      0.053848
      0.720933

      MLEIFCTX Index
      3.732976
      0.053682
      0.695382

      HDG US Equity
      2.810002
      0.056380
      0.498407

      QAI US Equity
      2.549052
      0.045484
      0.560434

### Question 2

2.  For the series in the “hedgefundseries” tab, , calculate the following statistics related to tail-risk.(a)  Skewness(b)  Excess Kurtosis (in excess of 3)(c)  VaR (.05) - the fifth quantile of historic returns(d)  CVaR (.05) - the mean of the returns at or below the fifth quantile(e)  Maximum drawdown - include the dates of the max/min/recovery within the max drawdownperiod.

```python
df_hfs_2 = df_hfs

skew =df_hfs_2.skew(axis = 0)

# Calculating kurtosis
from scipy.stats import kurtosis

kurtosis = pd.Series(index = df_hfs_2.skew(axis = 0).index, data = kurtosis(df_hfs_2, fisher=True, axis = 0))

# Calculating VaR(0.05)
VaR = pd.Series(index = df_hfs_2.skew(axis = 0).index, data = np.percentile(df_hfs, 0.05, axis = 0))

# Calculating CVaR(0.05) - - the mean of the returns at or below the fifth quantile
CVar = df_hfs_2[df_hfs_2 <= VaR].mean()

# Maximum drawdown
cum_return = (1+ df_hfs).cumprod()
rolling_max = cum_return.cummax()
drawdown = (cum_return - rolling_max) / rolling_max
maxDrawdown = drawdown.min()

bottom = pd.Series(pd.to_datetime([drawdown[col].idxmin() for col in drawdown]),index=df_hfs.columns)
peak = pd.Series(pd.to_datetime([(cum_return[col][:bottom[col]].idxmax()) for col in cum_return]),index=df_hfs.columns)

peakLevels = pd.Series([cum_return[col].loc[peak.loc[col]] for col in cum_return],index=df_hfs.columns)
recovered = []
for col in cum_return:
    for lev in cum_return[col][bottom[col]:]:
        if lev >= peakLevels[col]:
            recovered.append(cum_return.index[cum_return[col] == lev][0])
            break
```

```python
pd.DataFrame({'Skewness':skew,'Excess Kurtosis':kurtosis,'5% VaR':VaR,'5% CVaR':CVar,'Max Drawdown':maxDrawdown,'Peak':peak,'Bottom':bottom,'Recovered':recovered})
```

      Skewness
      Excess Kurtosis
      5% VaR
      5% CVaR
      Max Drawdown
      Peak
      Bottom
      Recovered

      HFRIFWI Index
      -1.109248
      6.408651
      -0.087645
      -0.090784
      -0.115473
      2019-12-31
      2020-03-31
      2020-08-31

      MLEIFCTR Index
      -0.186692
      2.282271
      -0.051986
      -0.053234
      -0.084500
      2019-12-31
      2020-03-31
      2020-11-30

      MLEIFCTX Index
      -0.171632
      2.230566
      -0.051204
      -0.052384
      -0.083673
      2019-12-31
      2020-03-31
      2020-11-30

      HDG US Equity
      -0.179641
      2.428275
      -0.057574
      -0.059191
      -0.088245
      2020-01-31
      2020-03-31
      2020-11-30

      QAI US Equity
      -0.545085
      2.512613
      -0.054617
      -0.056346
      -0.075649
      2019-12-31
      2020-03-31
      2020-07-31

```python
df_hfs_2.loc['max drawdown'] = drawdown.min()
drawdown.plot(figsize = (15,8),title = 'Drawdown')
```

### Question 3

 #### 3. For the series in the "hedge_fund_series" tab, run a regression of each against SPY. Include an intercept. Report the following regression-based statistics
     a. Maket Beta
     b. Treynor Ratio
     c. Information Ratio
 Annualize the three stats as appropriate

```python
## function to clean the data and replace missing values with 0
def clean(data):
    if data.isnull().values.any():

        return data.fillna(0)
    else:
        return data
```

```python
def market_beta(model, x,y, market = False):

    beta = model.params.to_frame('Parameters').loc[x.columns[1],'Parameters']

    return beta
```

```python
def Treynor_Ratio(model,x,y,periods):
    trey = []
    pred = model.predict(x).mean()*periods
    beta = model.params.to_frame('Parameters').loc[x.columns[1],'Parameters']
    trey = pred/beta
    return trey
```

```python
def Information_Ratio(model,x,y,periods):

    alpha = model.params.to_frame('Parameters').loc[x.columns[0],'Parameters']
    sigma = (y-model.predict(x)).std()
    info = alpha/(sigma*(periods**0.5))
    return info
```

```python
def regression(predicted_var, predicting_var, periods = 12, intercept = False):
    ## for the regression model
    y = predicted_var
    y = clean(y)

    x = predicting_var
    x = clean(x)
    if intercept == True:
        x = sm.add_constant(x)

    model  =sm.OLS(y,x,missing = 'drop').fit()

    meanr = y.mean()*periods
    s = meanr/(y.std()*(periods)**0.5)
    m = market_beta(model,x,y,market=True)
    t = Treynor_Ratio(model,x,y,periods)
    i = Information_Ratio(model,x,y,periods)
    r = model.rsquared

    return meanr,s,m,t, i,r

```

```python
mean_returns = {}
sharpe = {}
beta = {}
trey = {}
info = {}
r_sq = {}
for c in df_hedge_fund.columns:
    mean_returns[c],sharpe[c],beta[c], trey[c], info[c],r_sq[c] = regression(df_hedge_fund[c],S_and_P_data,intercept = True)

df_summary = pd.DataFrame([mean_returns,sharpe,r_sq,beta,trey,info])

df_summary = df_summary.T.rename(columns = {0:'Mean Returns (%)',1:'Sharpe Ratio',2:'R-squared',3:'Market Beta',4:'Treynor Ratio',5:'Information Ratio'})

df_summary['Mean Returns (%)'] = df_summary['Mean Returns (%)']*100

df_summary.loc[S_and_P_data.name] = [S_and_P_data.mean()*100,(S_and_P_data.mean()/S_and_P_data.std())*(periods)**0.5,1,1,'NA','NA']

df_summary
```

      Mean Returns (%)
      Sharpe Ratio
      R-squared
      Market Beta
      Treynor Ratio
      Information Ratio

      HFRIFWI Index
      5.078422
      0.825665
      0.753090
      0.394320
      0.128789
      -0.020169

      MLEIFCTR Index
      3.882076
      0.720933
      0.816158
      0.359382
      0.108021
      -0.051272

      MLEIFCTX Index
      3.732976
      0.695382
      0.815047
      0.358034
      0.104263
      -0.055939

      HDG US Equity
      2.810002
      0.498407
      0.785733
      0.369199
      0.076111
      -0.084218

      QAI US Equity
      2.549052
      0.560434
      0.719379
      0.284993
      0.089443
      -0.057274

      SPY US Equity
      1.229567
      1.090019
      1.000000
      1.000000
      NA
      NA

### Question 4

#### Discuss the previous statistics, and what they tell us about...
##### (a) the differences between SPY and the hedge-fund series?
##### (b) which performs better between HDG and QAI.
##### (c) whether HDG and the ML series capture the most notable properties of HFRI.

#### a. All the hedge funds got better mean returns while having a higher sharpe ratio. For a risk averse investor, S&P could have been a  better bet.
   #### All the hedge funds had a low information ratio but a high R-squared, this means that they took on a bit more risk but did not deviate too far from the S&P as it can explain so much of the variance of the hedge funds. For the hedge funds with sharpe ratios close to S&P's sharpe ratio (HFRIFWI and MLEIFCTR), the fund managers were able to mean - variance optimize the portfolio well

#### b. HDG has a slightly higher mean return but lower values of Sharpe and Information ratios as compared to QAI. This means that the extra performance came at a cost of higher volatility and probably involved a bit of luck as compared to QAI. Overall it is close but QAI has slightly better statistics

#### c. HFRI has a very high correaltion with the ML series and HDG. Also all the 4 series have a very similar Beta with the S&P, which probably implies that they similar average volatilites. Overall, on average HDG and ML do capture the most notable features of HFRI but they will probably provide lesser mean returns  along with lower volatility and a less risk of major loss as HFRI's tails are fatter. This is we believe is a case of average returns and average volatility masking the risky tail events.

### Question 5

5.  Report the correlation matrix for these assets.(a)  Show the correlations as a heat map.(b)  Which series have the highest and lowest correlations?

```python
path_to_data_file = 'proshares_analysis_data.xlsx'
df = pd.read_excel(path_to_data_file,sheet_name='hedge_fund_series')
```

```python
df
```

      date
      HFRIFWI Index
      MLEIFCTR Index
      MLEIFCTX Index
      HDG US Equity
      QAI US Equity

      0
      2011-08-31
      -0.032149
      -0.025588
      -0.025689
      -0.027035
      -0.006491

      1
      2011-09-30
      -0.038903
      -0.032414
      -0.032593
      -0.032466
      -0.022142

      2
      2011-10-31
      0.026858
      0.043593
      0.043320
      0.050532
      0.025241

      3
      2011-11-30
      -0.013453
      -0.012142
      -0.012431
      -0.028608
      -0.007965

      4
      2011-12-31
      -0.004479
      0.001938
      0.001796
      0.012875
      0.001854

      ...
      ...
      ...
      ...
      ...
      ...
      ...

      117
      2021-05-31
      0.013401
      0.000701
      0.000666
      -0.000784
      0.005940

      118
      2021-06-30
      0.005424
      0.005985
      0.005991
      0.001052
      0.002485

      119
      2021-07-31
      -0.009095
      -0.013890
      -0.013766
      -0.012520
      -0.003409

      120
      2021-08-31
      0.007628
      0.009262
      0.009286
      0.007066
      0.001245

      121
      2021-09-30
      0.000000
      -0.016711
      -0.016641
      -0.013486
      -0.013360

122 rows × 6 columns

```python
df.corr()
```

      HFRIFWI Index
      MLEIFCTR Index
      MLEIFCTX Index
      HDG US Equity
      QAI US Equity

      HFRIFWI Index
      1.000000
      0.910392
      0.910063
      0.897653
      0.866714

      MLEIFCTR Index
      0.910392
      1.000000
      0.999939
      0.984621
      0.864431

      MLEIFCTX Index
      0.910063
      0.999939
      1.000000
      0.984471
      0.863734

      HDG US Equity
      0.897653
      0.984621
      0.984471
      1.000000
      0.847597

      QAI US Equity
      0.866714
      0.864431
      0.863734
      0.847597
      1.000000

```python
sns.heatmap(df.corr())
```

### HDG US Equity and QAI US Equity has lowest correlation; MLEIFCTR Index and MLEIFCTX Index has highest correlation.

### Question 6
#####
Replicate HFRI with the six factors listed on the “merrillfactors” tab. Include a constant, and run the unrestricted regression,

  \begin{align}
  r^{hfri}_{t} = \alpha^{merr} + x^{merr}_{t} \beta^{merr} + \epsilon^{merr}_{t} (1)
  \end{align}

  \begin{align}
  \hat r^{hfri}_{t} = \hat\alpha^{merr} + x^{merr}_{t} \hat \beta^{merr} (2)
  \end{align}

### (a)  Report the intercept and betas.
  (b)  Are the betas realistic position sizes, or do they require huge long-short positions?
  (c)  Report the R-squared.
  (d)  Report the volatility of $\epsilon^{merr}$ (the tracking error.)

```python
from dataclasses import dataclass
import warnings
# regression function
@dataclass
class RegressionsOutput:
    excess_ret_stats: pd.DataFrame
    params: pd.DataFrame
    residuals: pd.DataFrame
    tstats: pd.DataFrame
    other: pd.DataFrame
    df: pd.DataFrame

def lfm_time_series_regression(df, portfolio_names, factors, annualize_factor=12):
    excess_ret_stats = pd.DataFrame(index=factors, columns=['average', 'std'], dtype=float)
    for factor in factors:
        excess_ret_stats.loc[factor, 'average'] = annualize_factor * df[factor].mean()
        excess_ret_stats.loc[factor, 'std'] = np.sqrt(annualize_factor) * df[factor].std()
        excess_ret_stats.loc[factor, 'sharpe_ratio'] = \
            excess_ret_stats.loc[factor, 'average'] / excess_ret_stats.loc[factor, 'std']
        # Here I'll just report the unscaled skewness
        excess_ret_stats.loc[factor, 'skewness'] = df[factor].skew()
        # excess_ret_stats.loc[factor, 'skewness'] = annualize_factor * df[factor].skew()

    _temp_excess_ret_stats = excess_ret_stats.copy()
    _temp_excess_ret_stats.loc['const', :] = 0

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        rhs = sm.add_constant(df[factors])
    df_params = pd.DataFrame(columns=portfolio_names)
    df_other = pd.DataFrame(columns=portfolio_names)
    df_residuals = pd.DataFrame(columns=portfolio_names)
    df_tstats = pd.DataFrame(columns=portfolio_names)
    for portfolio in portfolio_names:
        lhs = df[portfolio]
        res = sm.OLS(lhs, rhs, missing='drop').fit()
        df_params[portfolio] = res.params
        df_params.loc['const', portfolio] = annualize_factor * res.params['const']

        df_other.loc['r_squared', portfolio] = res.rsquared
        df_other.loc['model_implied_excess_ret', portfolio] = df_params[portfolio] @ _temp_excess_ret_stats['average']
        df_other.loc['ave_excess_ret', portfolio] = \
            annualize_factor * df[portfolio].mean()
        df_other.loc['std_excess_ret', portfolio] = \
            np.sqrt(annualize_factor) * df[portfolio].std()
        df_other.loc['skewness_excess_ret', portfolio] = \
            annualize_factor * df[portfolio].skew()
        df_other.loc['sharpe_ratio', portfolio] = \
            df_other.loc['ave_excess_ret', portfolio] / df_other.loc['std_excess_ret', portfolio]
        df_residuals[portfolio] = res.resid
        df_tstats[portfolio] = res.tvalues

    regression_outputs = RegressionsOutput(
        excess_ret_stats.T,
        df_params.T,
        df_residuals,
        df_tstats.T,
        df_other.T,
        df)

    return regression_outputs
```

```python
df_hfs = pd.read_excel('proshares_analysis_data.xlsx', sheet_name = 'hedge_fund_series').set_index('date')
df_mf = pd.read_excel('proshares_analysis_data.xlsx', sheet_name = 'merrill_factors').set_index('date')

merrill_factors = ['SPY US Equity', 'USGG3M Index', 'EEM US Equity', 'EFA US Equity', 'EUO US Equity', 'IWM US Equity']

df_mf['HFRIFWI Index'] = df_hfs['HFRIFWI Index']

prob6_regs = lfm_time_series_regression(df=df_mf, portfolio_names=['HFRIFWI Index'], factors=merrill_factors)
```

### (a) Report the intercept and betas.

```python
display(prob6_regs.params)
```

      const
      SPY US Equity
      USGG3M Index
      EEM US Equity
      EFA US Equity
      EUO US Equity
      IWM US Equity

      HFRIFWI Index
      0.01376
      0.072022
      -0.400591
      0.072159
      0.106318
      0.022431
      0.130892

### (b) Are the betas realistic position sizes, or do they require huge long-short positions?
These betas are realistic position sizes. Only the USGG3M is short and that might not be allowed

### (c) Report the R-squared.
### (d) Report the volatility of  E(merr), (the tracking error.)

```python
display(prob6_regs.other)

print('\nR-squared = {:.5f}'.format(np.array(prob6_regs.other['r_squared'])[0]))
print('Tracking error = {:.5f}'.format(np.array(prob6_regs.residuals.std() * np.sqrt(12))[0]))
```

      r_squared
      model_implied_excess_ret
      ave_excess_ret
      std_excess_ret
      skewness_excess_ret
      sharpe_ratio

      HFRIFWI Index
      0.855695
      0.037024
      0.050784
      0.061507
      -13.310979
      0.825665

    R-squared = 0.85569
    Tracking error = 0.02337

## 7. Let's examine the replication out-of-sample.

```python
date_range = df_mf.iloc[60:, :].index
oos_fitted = pd.Series(index = date_range, name = 'OOS_fit', dtype='float64')
for i in range(60, len(df_mf)):
    date = df_mf.iloc[i:i+1, :].index
    # date_month_prior = pd.DatetimeIndex([date]).shift(periods = -1, freq = 'M')[0]
    df_subset = df_mf.iloc[i-60:i, :]
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        rhs = sm.add_constant(df_subset[merrill_factors])
    lhs = df_subset['HFRIFWI Index']
    res = sm.OLS(lhs, rhs, drop="missing").fit()
    alpha = res.params['const']
    beta = res.params.drop(index='const')
    x_t = df_mf.loc[date, merrill_factors]
    predicted_next_value = alpha + x_t @ beta
    oos_fitted[date] = predicted_next_value

oos_fitted.plot(figsize=(14,3))
df_mf.iloc[60:,:]['HFRIFWI Index'].plot()
plt.legend()
plt.show()
None

display((pd.DataFrame([oos_fitted, df_mf.iloc[60:,:]['HFRIFWI Index']])).T.corr())
```

The OOS results perform well, showing almost the same level of replicability - 94.5% correlation between the replicating portfolio and the HFRI.

# 8. (a) regression beta

```python
from dataclasses import dataclass
import warnings
df_hfs = pd.read_excel('proshares_analysis_data.xlsx', sheet_name = 'hedge_fund_series').set_index('date')
df_mf = pd.read_excel('proshares_analysis_data.xlsx', sheet_name = 'merrill_factors').set_index('date')
# regression function
@dataclass
class RegressionsOutput:
    excess_ret_stats: pd.DataFrame
    params: pd.DataFrame
    residuals: pd.DataFrame
    tstats: pd.DataFrame
    other: pd.DataFrame
    df: pd.DataFrame

def lfm_time_series_regression(df, portfolio_names, factors, annualize_factor=12):
    excess_ret_stats = pd.DataFrame(index=factors, columns=['average', 'std'], dtype=float)
    for factor in factors:
        excess_ret_stats.loc[factor, 'average'] = annualize_factor * df[factor].mean()
        excess_ret_stats.loc[factor, 'std'] = np.sqrt(annualize_factor) * df[factor].std()
        excess_ret_stats.loc[factor, 'sharpe_ratio'] = \
            excess_ret_stats.loc[factor, 'average'] / excess_ret_stats.loc[factor, 'std']
        # Here I'll just report the unscaled skewness
        excess_ret_stats.loc[factor, 'skewness'] = df[factor].skew()
        # excess_ret_stats.loc[factor, 'skewness'] = annualize_factor * df[factor].skew()

    _temp_excess_ret_stats = excess_ret_stats.copy()
    _temp_excess_ret_stats.loc['const', :] = 0

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        rhs = sm.add_constant(df[factors])
    df_params = pd.DataFrame(columns=portfolio_names)
    df_other = pd.DataFrame(columns=portfolio_names)
    df_residuals = pd.DataFrame(columns=portfolio_names)
    df_tstats = pd.DataFrame(columns=portfolio_names)
    for portfolio in portfolio_names:
        lhs = df[portfolio]
        res = sm.OLS(lhs, rhs, missing='drop').fit()
        df_params[portfolio] = res.params
        df_params.loc['const', portfolio] = 0

        df_other.loc['r_squared', portfolio] = res.rsquared
        df_other.loc['model_implied_excess_ret', portfolio] = df_params[portfolio] @ _temp_excess_ret_stats['average']
        df_other.loc['ave_excess_ret', portfolio] = \
            annualize_factor * df[portfolio].mean()
        df_other.loc['std_excess_ret', portfolio] = \
            np.sqrt(annualize_factor) * df[portfolio].std()
        df_other.loc['skewness_excess_ret', portfolio] = \
            annualize_factor * df[portfolio].skew()
        df_other.loc['sharpe_ratio', portfolio] = \
            df_other.loc['ave_excess_ret', portfolio] / df_other.loc['std_excess_ret', portfolio]
        df_residuals[portfolio] = res.resid
        df_tstats[portfolio] = res.tvalues

    regression_outputs = RegressionsOutput(
        excess_ret_stats.T,
        df_params.T,
        df_residuals,
        df_tstats.T,
        df_other.T,
        df)

    return regression_outputs
```

```python
merrill_factors = ['SPY US Equity', 'USGG3M Index', 'EEM US Equity', 'EFA US Equity', 'EUO US Equity', 'IWM US Equity']

df_mf['HFRIFWI Index'] = df_hfs['HFRIFWI Index']

prob8_regs = lfm_time_series_regression(df=df_mf, portfolio_names=['HFRIFWI Index'], factors=merrill_factors)
display(prob8_regs.params)
```

      const
      SPY US Equity
      USGG3M Index
      EEM US Equity
      EFA US Equity
      EUO US Equity
      IWM US Equity

      HFRIFWI Index
      0.0
      0.072022
      -0.400591
      0.072159
      0.106318
      0.022431
      0.130892

# 8(b) mean of fitted value

#### the mean of HFRI is

```python
print(df_hfs['HFRIFWI Index'].mean()*12)
```

    0.050784216013560556

```python

a=df_mf['SPY US Equity'].mean()
b=df_mf['USGG3M Index'].mean()
c=df_mf['EEM US Equity'].mean()
d=df_mf['EFA US Equity'].mean()
e=df_mf['EUO US Equity'].mean()
f=df_mf['IWM US Equity'].mean()
mean_fitted=(0.072022*a-0.400591*b+0.072159*c+0.106318*d+0.022431*e+0.130892*f)*12

```

#### the mean of fitted value is

```python
print(mean_fitted)
```

    0.037023779152482235

#### so we can see that compared to mean of MFRI, mean of fitted value is much lower

# 8(c) The correlation

#### parameters without interercept

```python
 prob8_regs.params
```

      const
      SPY US Equity
      USGG3M Index
      EEM US Equity
      EFA US Equity
      EUO US Equity
      IWM US Equity

      HFRIFWI Index
      0.0
      0.072022
      -0.400591
      0.072159
      0.106318
      0.022431
      0.130892

#### parameters with interercept

```python
prob6_regs.params
```

      const
      SPY US Equity
      USGG3M Index
      EEM US Equity
      EFA US Equity
      EUO US Equity
      IWM US Equity

      HFRIFWI Index
      0.01376
      0.072022
      -0.400591
      0.072159
      0.106318
      0.022431
      0.130892

```python

a=df_mf['SPY US Equity']
b=df_mf['USGG3M Index']
c=df_mf['EEM US Equity']
d=df_mf['EFA US Equity']
e=df_mf['EUO US Equity']
f=df_mf['IWM US Equity']
k=[]
g=[]
t=[]

for i in range(len(a)):
    fitted_no_intece=0.072022*a[i]-0.400591*b[i]+0.072159*c[i]+0.106318*d[i]+0.022431*e[i]+0.130892*f[i]
    k.append(fitted_no_intece)
    fitted_intece=0.01376+0.072022*a[i]-0.400591*b[i]+0.072159*c[i]+0.106318*d[i]+0.022431*e[i]+0.130892*f[i]
    g.append(fitted_intece)
    t.append(df_hfs['HFRIFWI Index'][i])
df_corr = pd.DataFrame({'HFRIFWI Index':df_hfs['HFRIFWI Index'],'Predicted with intercept':g,'Predicted witout intercept':k})

df_corr.corr()[['HFRIFWI Index']]
```

      HFRIFWI Index

      HFRIFWI Index
      1.000000

      Predicted with intercept
      0.925038

      Predicted witout intercept
      0.925038

#### the correlation is very similar so I think they can fit replicators without intercepts as this will help them replicate the index

```python

```