```python
import sympy as sp
import tensorflow as tf
import tensorflow.python.keras.backend as K
import tensorflow_probability as tfp

import MLGeometry as mlg
from MLGeometry import bihomoNN as bnn

import math
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

np.random.seed(42)
tf.random.set_seed(42)
```

    Hello from BihomoNN! Arbitrary dimension!

```python
# Matplotlib options
%matplotlib inline
#%matplotlib notebook
plt.style.use('dark_background')
plt.rcParams['figure.dpi'] = 200
plt.rcParams['figure.figsize'] = [4, 2]
plt.rcParams['text.usetex'] = True
```

## Modules

```python
# Plot training get_loss_hist
def plot_train_hist(model, val=False, y_scale='linear'):
    x1 = np.arange(1, len(model.get_loss_hist())+1)
    plt.scatter(x1, model.get_loss_hist(), marker='.', label='Training loss' )
    #if val:
    #    plt.scatter(x1, model.get_loss_hist['val_loss'], marker='.', label='Validation loss')
    plt.xlabel('Training epochs')
    plt.ylabel('Loss')
    plt.yscale(y_scale)
    plt.legend()
    print("Final loss: ", model.get_loss_hist()[-1])
    return plt.show()
```

```python
def cal_total_loss(dataset, hermitian_met, kahler_pot, loss_function, c1):
    total_loss = tf.constant(0, dtype=tf.float32)
    total_mass = tf.constant(0, dtype=tf.float32)

    for step, (points, Omega_Omegabar, mass, restriction) in enumerate(dataset):
        curv_tr = curvature_trace(points, kahler_pot, hermitian_met, mass)
        mass_sum = tf.reduce_sum(mass)
        total_loss += loss_function(c1, curv_tr, mass)  * mass_sum
        total_mass += mass_sum
    total_loss = total_loss / total_mass

    return total_loss.numpy()

```

Networks for $\mathcal{O}_X(1)$, $\mathcal{O}_X(2)$, $\mathcal{O}_X(4)$ respectively.

\begin{itemize}
\item "dim" is the number of projective coordinates of points on the CY hypersurface.
\item "vec" is a vector giving the number of neurons for layers 1 through d-1
\item "rank" is the rank of the vector bundle -- so far should just be 1
\end{itemize}

```python
class Hermitian_metric_O1(tf.keras.Model):
    def __init__(self, dim, rank, vec):
        super(Hermitian_metric_O1, self).__init__()
        self.dim = dim
        self.rank = rank
        self.vec = vec
        self.loss_hist = []
        print("Bundle rank", self.rank)
        self.bihomogeneous = bnn.Bihomogeneous(self.dim)
        self.layer1 = bnn.Dense(self.dim**2, self.rank)

    def append_loss(self, loss):
        self.loss_hist.append([loss])

    def get_loss_hist(self):
        return self.loss_hist

    def call(self, inputs):
        x = self.bihomogeneous(inputs)
        x = tf.math.log(x)
        return x

class Hermitian_metric_O2(tf.keras.Model):
    def __init__(self, dim, rank, vec):
        super(Hermitian_metric_O2, self).__init__()
        self.dim = dim
        self.rank = rank
        self.vec = vec
        self.loss_hist = []
        print("Bundle rank", self.rank)
        self.bihomogeneous = bnn.Bihomogeneous(self.dim)
        self.layer1 = bnn.Dense(self.dim**2, self.vec[0], activation=tf.square)
        self.layer2 = bnn.Dense(self.vec[0], self.rank)

    def append_loss(self, loss):
        self.loss_hist.append([loss])

    def get_loss_hist(self):
        return self.loss_hist

    def call(self, inputs):
        x = self.bihomogeneous(inputs)
        x = self.layer1(x)
        x = self.layer2(x)
        x = tf.math.log(x)
        return x

class Hermitian_metric_O4(tf.keras.Model):
    def __init__(self, dim, rank, vec):
        super(Hermitian_metric_O4, self).__init__()
        self.dim = dim
        self.rank = rank
        self.vec = vec
        self.loss_hist = []
        print("Bundle rank", self.rank)
        self.bihomogeneous = bnn.Bihomogeneous(self.dim)
        self.layer1 = bnn.Dense(dim**2, self.vec[0], activation=tf.square)
        self.layer2 = bnn.Dense(self.vec[0], self.vec[1], activation=tf.square)
        self.layer3 = bnn.Dense(self.vec[1], self.rank)

    def append_loss(self, loss):
        self.loss_hist.append([loss])

    def get_loss_hist(self):
        return self.loss_hist

    def call(self, inputs):
        x = self.bihomogeneous(inputs)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = tf.math.log(x)
        return x
```

## Curvature calculation

\begin{itemize}
\item Issue with normalization needs to be fixed here, in weighted_loss
\item Other issue is taking inverse of K\"ahler metric or curvature tensor when calculating $g^{i\bar{j}} F_{i\bar{j}}$
\end{itemize}

weighted_loss and curvature_trace are used together to determine

\begin{eqnarray}
  \label{eq:error-loss-num}
       E(\vec{\mathbf{w}}) &=&
       \frac{\sum_{\mathrm{points,} i} \left( g^{i\bar{j}}(p_i) F_{i \bar{j}}(\vec{\mathbf{w}}, p_i ) - \mu(L) \right) w_i ~}{  \sum_{\mathrm{points,} i} w_i } \\
       &:=& \frac{\left( \int_X g^{i\bar{j}} F_{i \bar{j}}(\vec{\mathbf{w}})  - \mu(L)~ \mathrm{dvol_{CY}}\right) }{ \int_X \mathrm{dvol{CY}}}
\end{eqnarray}

```python
def weighted_MAPE(y_true, y_pred, mass):
    weights = mass / K.sum(mass)
    return K.sum(K.abs(y_true - y_pred) / tf.cast(y_true, tf.float32) * weights)

def weighted_loss(y_true, y_pred, mass):
    weights = mass /K.sum(mass)
    #print(4"loss1:", K.sum( K.abs(y_pred - y_true) * weights))
    #print("loss2:", K.sum( K.abs(y_pred - y_true) * weights) / K.sum(weights))
    #return K.abs( K.sum( (y_pred - y_true) * weights) ) / K.sum(weights)
    return K.sum( K.abs(y_pred - y_true) * weights) / K.sum(weights)

@tf.function
def curvature_trace(points, kahler_pot, herm_metric, mass):
    kahler_metric = mlg.complex_math.complex_hessian(tf.math.real(kahler_pot(points)), points)
    #print("kahler_metric: ", kahler_metric)
    #inv_kahler_metric = tf.linalg.inv(kahler_metric)
    #print("inverse kahler_metric: ", inv_kahler_metric)
    curvature = mlg.complex_math.complex_hessian(tf.math.real(herm_metric(points)), points)
    #print("curvature", curvature)
    #inv_curvature = tf.linalg.inv(curvature)
    curvature_trace = tf.linalg.trace(tf.matmul(kahler_metric, curvature) )
    # print("contraction", tf.matmul(kahler_metric, curvature) )
    # print("curvature_trace: ", curvature_trace)
    return curvature_trace

```

## Generate points and load Kahler potential network

```python
z0, z1, z2 = sp.symbols('z0, z1, z2')
Z = [z0, z1, z2]
a, b =  -1, 1
f = z0**3 + a * (z0**2)*z2 - (z1**2)*z2 + b*(z2**3)
#f = z0**3 + z1**3 + z2**3 # Switch to Fermat curve
f
```

$\displaystyle z_{0}^{3} - z_{0}^{2} z_{2} - z_{1}^{2} z_{2} + z_{2}^{3}$

```python
# Create training and testing set
n_pairs = 10000
HS_train = mlg.hypersurface.Hypersurface(Z, f, n_pairs)
HS_test = mlg.hypersurface.Hypersurface(Z, f, n_pairs)
train_set = mlg.tf_dataset.generate_dataset(HS_train)
test_set = mlg.tf_dataset.generate_dataset(HS_test)
train_set = train_set.shuffle(HS_train.n_points).batch(1000)
test_set = test_set.shuffle(HS_test.n_points).batch(1000)
```

```python
# Load CY metric/Kahler potential
kahler_pot = tf.keras.models.load_model("ec_2_kahler_pot")
# kahler_pot = tf.keras.models.load_model("ec_fermat_kahler_pot") # Switch to fermat curve
```

    WARNING:tensorflow:No training configuration found in save file, so the model was *not* compiled. Compile it manually.

## O(1)

This has stopped working due to no gradients occuring - $\mathcal{O}_X(2)$ is the first that works

```python
# Define and train model for hermitian metric
hermitian_met = Hermitian_metric_O1(3, 1, [])
print("Hermitian metric G network defined")
c1 = tf.constant(1., shape=(1000), dtype=tf.complex64)
optimizer = tf.keras.optimizers.Adam()
loss_func = weighted_MAPE
loss_hist = []
max_epochs = 100
epoch = 0
#print("Beginning training")
while epoch < max_epochs:
    epoch = epoch + 1
    for step, (points, Omega_Omegabar, mass, restriction) in enumerate(train_set):
        with tf.GradientTape() as tape:
            # det_omega = nf.volume_form(points, Omega_Omegabar, mass, restriction, kahler_pot)
            curv_tr = curvature_trace(points, kahler_pot, hermitian_met, mass)
            #print("calculate curv_tr: ", curv_tr)
            #print("c1: ", c1)
            loss = loss_func(curv_tr, c1, mass)
            grads = tape.gradient(loss, hermitian_met.trainable_weights)
        optimizer.apply_gradients(zip(grads, hermitian_met.trainable_weights))
    #if epoch % 50 == 0:
    #    print("epoch %d: loss = %.7f" % (epoch, loss))
    loss_hist.append(loss)
    hermitian_met.append_loss(loss)
    print("epoch %d: loss = %.7f" % (epoch, loss))
```

```python
plot_train_hist(hermitian_met, y_scale='log')
```

## O(2) -- 2 layer network

Trains well, but performance seems random -- should run for longer, e.g. 500 or 1000 rounds

```python
# Define and train model for hermitian metric
hermitian_met = Hermitian_metric_O2(3, 1, [100])
print("Hermitian metric G network defined")
c1 = tf.constant(2., shape=(1000), dtype=tf.complex64)
optimizer = tf.keras.optimizers.Adam()
loss_func = weighted_loss
loss_hist = []
max_epochs = 100
epoch = 0
#print("Beginning training")
while epoch < max_epochs:
    epoch = epoch + 1
    # print("epoch: ", epoch)
    for step, (points, Omega_Omegabar, mass, restriction) in enumerate(train_set):
        # print("mass: ", mass)
        with tf.GradientTape() as tape:
            curv_tr = curvature_trace(points, kahler_pot, hermitian_met, mass)
            loss = loss_func(c1, curv_tr, mass)
            grads = tape.gradient(loss, hermitian_met.trainable_weights)
        optimizer.apply_gradients(zip(grads, hermitian_met.trainable_weights))
    #if epoch % 50 == 0:
    #    print("epoch %d: loss = %.7f" % (epoch, loss))
    loss_hist.append(loss)
    hermitian_met.append_loss(loss)
    print("epoch %d: loss = %.7f" % (epoch, loss))
```

    Bundle rank 1
    Hermitian metric G network defined
    epoch 1: loss = 0.4152077
    epoch 2: loss = 0.2462641
    epoch 3: loss = 0.2157634
    epoch 4: loss = 0.2029460
    epoch 5: loss = 0.1592798
    epoch 6: loss = 0.1958732
    epoch 7: loss = 0.1837285
    epoch 8: loss = 0.1640707
    epoch 9: loss = 0.1950198
    epoch 10: loss = 0.1634691
    epoch 11: loss = 0.1747584
    epoch 12: loss = 0.1678677
    epoch 13: loss = 0.1574437
    epoch 14: loss = 0.2061749
    epoch 15: loss = 0.1732367
    epoch 16: loss = 0.1668803
    epoch 17: loss = 0.1582795
    epoch 18: loss = 0.1635014
    epoch 19: loss = 0.1616841
    epoch 20: loss = 0.1654822
    epoch 21: loss = 0.1670287
    epoch 22: loss = 0.1636321
    epoch 23: loss = 0.1820322
    epoch 24: loss = 0.1637284
    epoch 25: loss = 0.1720325
    epoch 26: loss = 0.1514293
    epoch 27: loss = 0.1605239
    epoch 28: loss = 0.1696578
    epoch 29: loss = 0.1583610
    epoch 30: loss = 0.1473488
    epoch 31: loss = 0.1604525
    epoch 32: loss = 0.1580834
    epoch 33: loss = 0.1633692
    epoch 34: loss = 0.1609171
    epoch 35: loss = 0.1836044
    epoch 36: loss = 0.1642265
    epoch 37: loss = 0.1588560
    epoch 38: loss = 0.1647543
    epoch 39: loss = 0.1517842
    epoch 40: loss = 0.1552927
    epoch 41: loss = 0.1568677
    epoch 42: loss = 0.1402328
    epoch 43: loss = 0.1615713
    epoch 44: loss = 0.1630237
    epoch 45: loss = 0.1486375
    epoch 46: loss = 0.1712829
    epoch 47: loss = 0.1532562
    epoch 48: loss = 0.1557781
    epoch 49: loss = 0.1661922
    epoch 50: loss = 0.1530470
    epoch 51: loss = 0.1532053
    epoch 52: loss = 0.1743845
    epoch 53: loss = 0.1548539
    epoch 54: loss = 0.1685710
    epoch 55: loss = 0.1619197
    epoch 56: loss = 0.1535957
    epoch 57: loss = 0.1616617
    epoch 58: loss = 0.1587730
    epoch 59: loss = 0.1586005
    epoch 60: loss = 0.1597009
    epoch 61: loss = 0.1639083
    epoch 62: loss = 0.1620317
    epoch 63: loss = 0.1629054
    epoch 64: loss = 0.1610493
    epoch 65: loss = 0.1590642
    epoch 66: loss = 0.1519802
    epoch 67: loss = 0.1624671
    epoch 68: loss = 0.1541019
    epoch 69: loss = 0.1813453
    epoch 70: loss = 0.1562000
    epoch 71: loss = 0.1617375
    epoch 72: loss = 0.1674788
    epoch 73: loss = 0.1639657
    epoch 74: loss = 0.1533313
    epoch 75: loss = 0.1571904
    epoch 76: loss = 0.1549140
    epoch 77: loss = 0.1695131
    epoch 78: loss = 0.1573561
    epoch 79: loss = 0.1631309
    epoch 80: loss = 0.1628431
    epoch 81: loss = 0.1505865
    epoch 82: loss = 0.1560065
    epoch 83: loss = 0.1543439
    epoch 84: loss = 0.1588728
    epoch 85: loss = 0.1607314
    epoch 86: loss = 0.1547090
    epoch 87: loss = 0.1569202
    epoch 88: loss = 0.1476556
    epoch 89: loss = 0.1576844
    epoch 90: loss = 0.1688253
    epoch 91: loss = 0.1504010
    epoch 92: loss = 0.1534875
    epoch 93: loss = 0.1515639
    epoch 94: loss = 0.1562007
    epoch 95: loss = 0.1651022
    epoch 96: loss = 0.1643992
    epoch 97: loss = 0.1618128
    epoch 98: loss = 0.1546837
    epoch 99: loss = 0.1527925
    epoch 100: loss = 0.1575306

```python
plot_train_hist(hermitian_met, y_scale='log')
```

Validation/test data

```python
cal_total_loss(test_set, hermitian_met, kahler_pot, weighted_loss, c1)
```

    0.15755129

## O(4) -- 3 layer network

Seems to train reasonably well compared to the others,

```python
# Define and train model for hermitian metric
hermitian_met = Hermitian_metric_O4(3, 1, [10,100])
print("Hermitian metric G network defined")
c1 = tf.constant(4., shape=(1000), dtype=tf.complex64)
optimizer = tf.keras.optimizers.Adam()
loss_func = weighted_loss
max_epochs = 100
epoch = 0
while epoch < max_epochs:
    epoch = epoch + 1
    for step, (points, Omega_Omegabar, mass, restriction) in enumerate(train_set):
        with tf.GradientTape() as tape:
            curv_tr = curvature_trace(points, kahler_pot, hermitian_met, mass)
            #print("calculate curv_tr: ", curv_tr)
            #print("c1: ", c1)
            loss = loss_func(c1, curv_tr, mass)
            # print("calculated loss: ", loss)
            grads = tape.gradient(loss, hermitian_met.trainable_weights)
        optimizer.apply_gradients(zip(grads, hermitian_met.trainable_weights))
    #if epoch % 50 == 0:
    #    print("epoch %d: loss = %.7f" % (epoch, loss))
    hermitian_met.append_loss(loss)
    print("epoch %d: loss = %.7f" % (epoch, loss))
```

    Bundle rank 1
    Hermitian metric G network defined
    epoch 1: loss = 16.1619701
    epoch 2: loss = 16.0926819
    epoch 3: loss = 15.7837143
    epoch 4: loss = 15.8820648
    epoch 5: loss = 15.2283545
    epoch 6: loss = 13.9912577
    epoch 7: loss = 14.4588242
    epoch 8: loss = 14.9011517
    epoch 9: loss = 12.0699596
    epoch 10: loss = 10.6898298
    epoch 11: loss = 9.9705009
    epoch 12: loss = 9.4152222
    epoch 13: loss = 8.1927567
    epoch 14: loss = 8.4536619
    epoch 15: loss = 7.7269716
    epoch 16: loss = 7.8843541
    epoch 17: loss = 7.1163778
    epoch 18: loss = 7.0020380
    epoch 19: loss = 6.8990512
    epoch 20: loss = 6.7431631
    epoch 21: loss = 6.5468512
    epoch 22: loss = 6.4904361
    epoch 23: loss = 6.3272471
    epoch 24: loss = 5.8370357
    epoch 25: loss = 5.7695532
    epoch 26: loss = 5.4734135
    epoch 27: loss = 5.4484863
    epoch 28: loss = 5.5860062
    epoch 29: loss = 5.5218549
    epoch 30: loss = 4.9923086
    epoch 31: loss = 5.1361675
    epoch 32: loss = 5.0360775
    epoch 33: loss = 4.6790938
    epoch 34: loss = 4.6885009
    epoch 35: loss = 4.9743395
    epoch 36: loss = 5.0657535
    epoch 37: loss = 5.0638580
    epoch 38: loss = 4.9146247
    epoch 39: loss = 4.5109305
    epoch 40: loss = 4.4330177
    epoch 41: loss = 4.5572653
    epoch 42: loss = 4.4905415
    epoch 43: loss = 4.0743361
    epoch 44: loss = 4.4058719
    epoch 45: loss = 4.2345204
    epoch 46: loss = 4.1158266
    epoch 47: loss = 4.2447858
    epoch 48: loss = 4.0002770
    epoch 49: loss = 4.0262160
    epoch 50: loss = 4.4884753
    epoch 51: loss = 4.0712018
    epoch 52: loss = 3.9300516
    epoch 53: loss = 3.9105110
    epoch 54: loss = 3.6899264
    epoch 55: loss = 3.9316797
    epoch 56: loss = 3.5215936
    epoch 57: loss = 3.8225992
    epoch 58: loss = 3.6083593
    epoch 59: loss = 3.7701735
    epoch 60: loss = 3.6448138
    epoch 61: loss = 3.6074002
    epoch 62: loss = 3.5556650
    epoch 63: loss = 3.5611310
    epoch 64: loss = 3.5929937
    epoch 65: loss = 3.3116024
    epoch 66: loss = 3.2955284
    epoch 67: loss = 3.3489528
    epoch 68: loss = 3.2990873
    epoch 69: loss = 3.2215874
    epoch 70: loss = 3.0701132
    epoch 71: loss = 3.0362434
    epoch 72: loss = 2.9503191
    epoch 73: loss = 2.7335558
    epoch 74: loss = 2.6047492
    epoch 75: loss = 2.6860812
    epoch 76: loss = 2.7492003
    epoch 77: loss = 2.6220684
    epoch 78: loss = 2.4870026
    epoch 79: loss = 2.4642560
    epoch 80: loss = 2.5660193
    epoch 81: loss = 2.6012897
    epoch 82: loss = 2.3682883
    epoch 83: loss = 2.3888302
    epoch 84: loss = 2.2464018
    epoch 85: loss = 2.4468441
    epoch 86: loss = 2.2873759
    epoch 87: loss = 2.2114718
    epoch 88: loss = 2.3252013
    epoch 89: loss = 2.1820478
    epoch 90: loss = 2.2719176
    epoch 91: loss = 2.2330120
    epoch 92: loss = 2.0957034
    epoch 93: loss = 2.2080102
    epoch 94: loss = 2.0936234
    epoch 95: loss = 2.0373731
    epoch 96: loss = 1.9995277
    epoch 97: loss = 2.0893321
    epoch 98: loss = 1.9674196
    epoch 99: loss = 1.9463624
    epoch 100: loss = 1.8780022

```python
plot_train_hist(hermitian_met, y_scale='linear')
```

Run this again and see:

```python
# Define and train model for hermitian metric
hermitian_met = Hermitian_metric_O4(3, 1, [10,100])
print("Hermitian metric G network defined")
c1 = tf.constant(4., shape=(1000), dtype=tf.complex64)
optimizer = tf.keras.optimizers.Adam()
loss_func = weighted_loss
max_epochs = 100
epoch = 0
while epoch < max_epochs:
    epoch = epoch + 1
    for step, (points, Omega_Omegabar, mass, restriction) in enumerate(train_set):
        with tf.GradientTape() as tape:
            curv_tr = curvature_trace(points, kahler_pot, hermitian_met, mass)
            #print("calculate curv_tr: ", curv_tr)
            #print("c1: ", c1)
            loss = loss_func(c1, curv_tr, mass)
            # print("calculated loss: ", loss)
            grads = tape.gradient(loss, hermitian_met.trainable_weights)
        optimizer.apply_gradients(zip(grads, hermitian_met.trainable_weights))
    #if epoch % 50 == 0:
    #    print("epoch %d: loss = %.7f" % (epoch, loss))
    hermitian_met.append_loss(loss)
    print("epoch %d: loss = %.7f" % (epoch, loss))
```

    Bundle rank 1
    Hermitian metric G network defined

    /home/rehandeen/miniconda3/envs/ml/lib/python3.7/site-packages/tensorflow/python/framework/indexed_slices.py:437: UserWarning: Converting sparse IndexedSlices(IndexedSlices(indices=Tensor("gradients/boolean_mask/GatherV2_grad/Reshape_1:0", shape=(None,), dtype=int64), values=Tensor("gradients/boolean_mask/GatherV2_grad/Reshape:0", shape=(None, None), dtype=float32), dense_shape=Tensor("gradients/boolean_mask/GatherV2_grad/Cast:0", shape=(2,), dtype=int32))) to a dense Tensor of unknown shape. This may consume a large amount of memory.
      "shape. This may consume a large amount of memory." % value)

    epoch 1: loss = 5.9414091
    epoch 2: loss = 2.9912207
    epoch 3: loss = 1.9286295
    epoch 4: loss = 1.4754308
    epoch 5: loss = 1.1345481
    epoch 6: loss = 0.8580085
    epoch 7: loss = 0.6426318
    epoch 8: loss = 0.5624699
    epoch 9: loss = 0.4555495
    epoch 10: loss = 0.4347323
    epoch 11: loss = 0.3900386
    epoch 12: loss = 0.3674594
    epoch 13: loss = 0.3561155
    epoch 14: loss = 0.3214222
    epoch 15: loss = 0.3165632
    epoch 16: loss = 0.3131433
    epoch 17: loss = 0.2853901
    epoch 18: loss = 0.2570019
    epoch 19: loss = 0.2776822
    epoch 20: loss = 0.2827359
    epoch 21: loss = 0.2617073
    epoch 22: loss = 0.2341670
    epoch 23: loss = 0.2547054
    epoch 24: loss = 0.2430686
    epoch 25: loss = 0.2345116
    epoch 26: loss = 0.2305654
    epoch 27: loss = 0.2142126
    epoch 28: loss = 0.2433473
    epoch 29: loss = 0.2244129
    epoch 30: loss = 0.2143625
    epoch 31: loss = 0.2179560
    epoch 32: loss = 0.2081591
    epoch 33: loss = 0.1982332
    epoch 34: loss = 0.1954504
    epoch 35: loss = 0.2131920
    epoch 36: loss = 0.2015739
    epoch 37: loss = 0.2305093
    epoch 38: loss = 0.2124467
    epoch 39: loss = 0.2116215
    epoch 40: loss = 0.1979855
    epoch 41: loss = 0.1938309
    epoch 42: loss = 0.2130062
    epoch 43: loss = 0.1909731
    epoch 44: loss = 0.2037583
    epoch 45: loss = 0.1962481
    epoch 46: loss = 0.2046558
    epoch 47: loss = 0.1986602
    epoch 48: loss = 0.1911331
    epoch 49: loss = 0.1798316
    epoch 50: loss = 0.1768410
    epoch 51: loss = 0.2002125
    epoch 52: loss = 0.2017663
    epoch 53: loss = 0.1951674
    epoch 54: loss = 0.1897479
    epoch 55: loss = 0.1786035
    epoch 56: loss = 0.1803044
    epoch 57: loss = 0.2062528
    epoch 58: loss = 0.1966084
    epoch 59: loss = 0.1810085
    epoch 60: loss = 0.1676497
    epoch 61: loss = 0.1984845
    epoch 62: loss = 0.1697184
    epoch 63: loss = 0.1712710
    epoch 64: loss = 0.1984183
    epoch 65: loss = 0.1964389
    epoch 66: loss = 0.1868788
    epoch 67: loss = 0.1702623
    epoch 68: loss = 0.1956623
    epoch 69: loss = 0.2240686
    epoch 70: loss = 0.2086210
    epoch 71: loss = 0.2159848
    epoch 72: loss = 0.1615828
    epoch 73: loss = 0.1748370
    epoch 74: loss = 0.1797713
    epoch 75: loss = 0.2128903
    epoch 76: loss = 0.1500576
    epoch 77: loss = 0.2013763
    epoch 78: loss = 0.1964080
    epoch 79: loss = 0.1935637
    epoch 80: loss = 0.1775040
    epoch 81: loss = 0.1723057
    epoch 82: loss = 0.1678380
    epoch 83: loss = 0.1673890
    epoch 84: loss = 0.1701850
    epoch 85: loss = 0.1652300
    epoch 86: loss = 0.2372279
    epoch 87: loss = 0.1664793
    epoch 88: loss = 0.1644403
    epoch 89: loss = 0.1554614
    epoch 90: loss = 0.1628489
    epoch 91: loss = 0.1864470
    epoch 92: loss = 0.1729120
    epoch 93: loss = 0.1971722
    epoch 94: loss = 0.1625303
    epoch 95: loss = 0.1628306
    epoch 96: loss = 0.1592305
    epoch 97: loss = 0.1583232
    epoch 98: loss = 0.1645751
    epoch 99: loss = 0.1836185
    epoch 100: loss = 0.1767960

```python
plot_train_hist(hermitian_met, y_scale='linear')
```

Validation/test data

```python
cal_total_loss(test_set, hermitian_met, kahler_pot, weighted_loss, c1)
```

    0.17504025


# Customer Segmentation: Estimate Individualized Responses to Incentives

Nowadays, business decision makers rely on estimating the causal effect of interventions to answer what-if questions about shifts in strategy, such as promoting specific product with discount, adding new features to a website or increasing investment from a sales team. However, rather than learning whether to take action for a specific intervention for all users, people are increasingly interested in understanding the different responses from different users to the two alternatives. Identifying the characteristics of users having the strongest response for the intervention could help make rules to segment the future users into different groups. This can help optimize the policy to use the least resources and get the most profit.

In this case study, we will use a personalized pricing example to explain how the [EconML](https://aka.ms/econml) library could fit into this problem and provide robust and reliable causal solutions.

### Summary

1. [Background](#background)
2. [Data](#data)
3. [Get Causal Effects with EconML](#estimate)
4. [Understand Treatment Effects with EconML](#interpret)
5. [Make Policy Decisions with EconML](#policy)
6. [Conclusions](#conclusion)

# Background

The global online media market is growing fast over the years. Media companies are always interested in attracting more users into the market and encouraging them to buy more songs or become members. In this example, we'll consider a scenario where one experiment a media company is running is to give small discount (10%, 20% or 0) to their current users based on their income level in order to boost the likelihood of their purchase. The goal is to understand the **heterogeneous price elasticity of demand** for people with different income level, learning which users would respond most strongly to a small discount. Furthermore, their end goal is to make sure that despite decreasing the price for some consumers, the demand is raised enough to boost the overall revenue.

EconML’s `DML` based estimators can be used to take the discount variation in existing data, along with a rich set of user features, to estimate heterogeneous price sensitivities that vary with multiple customer features. Then, the `SingleTreeCateInterpreter` provides a presentation-ready summary of the key features that explain the biggest differences in responsiveness to a discount, and the `SingleTreePolicyInterpreter` recommends a policy on who should receive a discount in order to increase revenue (not only demand), which could help the company to set an optimal price for those users in the future.

```python
# Some imports to get us started
# Utilities
import os
import urllib.request
import numpy as np
import pandas as pd

# Generic ML imports
from sklearn.preprocessing import PolynomialFeatures
from sklearn.ensemble import GradientBoostingRegressor

# EconML imports
from econml.dml import LinearDML, CausalForestDML
from econml.cate_interpreter import SingleTreeCateInterpreter, SingleTreePolicyInterpreter

import matplotlib.pyplot as plt

%matplotlib inline
```

# Data

The dataset* has ~10,000 observations and includes 9 continuous and categorical variables that represent user's characteristics and online behaviour history such as age, log income, previous purchase, previous online time per week, etc.

We define the following variables:

Feature Name|Type|Details
:--- |:---|:---
**account_age** |W| user's account age
**age** |W|user's age
**avg_hours** |W| the average hours user was online per week in the past
**days_visited** |W| the average number of days user visited the website per week in the past
**friend_count** |W| number of friends user connected in the account
**has_membership** |W| whether the user had membership
**is_US** |W| whether the user accesses the website from the US
**songs_purchased** |W| the average songs user purchased per week in the past
**income** |X| user's income
**price** |T| the price user was exposed during the discount season (baseline price * small discount)
**demand** |Y| songs user purchased during the discount season

**To protect the privacy of the company, we use the simulated data as an example here. The data is synthetically generated and the feature distributions don't correspond to real distributions. However, the feature names have preserved their names and meaning.*

The treatment and outcome are generated using the following functions:
$$
T =
\begin{cases}
  1 & \text{with } p=0.2,  \\
  0.9 & \text{with }p=0.3, & \text{if income}<1 \\
  0.8 & \text{with }p=0.5, \\
  \\
    1 & \text{with }p=0.7, \\
  0.9 & \text{with }p=0.2, & \text{if income}\ge1 \\
  0.8 & \text{with }p=0.1, \\
\end{cases}
$$

\begin{align}
\gamma(X) & = -3 - 14 \cdot \{\text{income}<1\} \\
\beta(X,W) & = 20 + 0.5 \cdot \text{avg_hours} + 5 \cdot \{\text{days_visited}>4\} \\
Y &= \gamma(X) \cdot T + \beta(X,W)
\end{align}

```python
# Import the sample pricing data
file_url = "https://msalicedatapublic.blob.core.windows.net/datasets/Pricing/pricing_sample.csv"
train_data = pd.read_csv(file_url)
```

```python
# Data sample
train_data.head()
```

      account_age
      age
      avg_hours
      days_visited
      friends_count
      has_membership
      is_US
      songs_purchased
      income
      price
      demand

      0
      3
      53
      1.834234
      2
      8
      1
      1
      4.903237
      0.960863
      1.0
      3.917117

      1
      5
      54
      7.171411
      7
      9
      0
      1
      3.330161
      0.732487
      1.0
      11.585706

      2
      3
      33
      5.351920
      6
      9
      0
      1
      3.036203
      1.130937
      1.0
      24.675960

      3
      2
      34
      6.723551
      0
      8
      0
      1
      7.911926
      0.929197
      1.0
      6.361776

      4
      4
      30
      2.448247
      5
      8
      1
      0
      7.148967
      0.533527
      0.8
      12.624123

```python
# Define estimator inputs
Y = train_data["demand"]  # outcome of interest
T = train_data["price"]  # intervention, or treatment
X = train_data[["income"]]  # features
W = train_data.drop(columns=["demand", "price", "income"])  # confounders
```

```python
# Get test data
X_test = np.linspace(0, 5, 100).reshape(-1, 1)
X_test_data = pd.DataFrame(X_test, columns=["income"])
```

# Get Causal Effects with EconML
To learn the price elasticity on demand as a function of income, we fit the model as follows:

\begin{align}
log(Y) & = \theta(X) \cdot log(T) + f(X,W) + \epsilon \\
log(T) & = g(X,W) + \eta
\end{align}

where $\epsilon, \eta$ are uncorrelated error terms.

The models we fit here aren't an exact match for the data generation function above, but if they are a good approximation, they will allow us to create a good discount policy.  Although the model is misspecified, we hope to see that our `DML` based estimators can still capture the right trend of $\theta(X)$ and that the recommended policy beats other baseline policies (such as always giving a discount) on revenue.  Because of the mismatch between the data generating process and the model we're fitting, there isn't a single true $\theta(X)$ (the true elasticity varies with not only X but also T and W), but given how we generate the data above, we can still calculate the range of true $\theta(X)$ to compare against.

```python
# Define underlying treatment effect function given DGP
def gamma_fn(X):
    return -3 - 14 * (X["income"] < 1)

def beta_fn(X):
    return 20 + 0.5 * (X["avg_hours"]) + 5 * (X["days_visited"] > 4)

def demand_fn(data, T):
    Y = gamma_fn(data) * T + beta_fn(data)
    return Y

def true_te(x, n, stats):
    if x < 1:
        subdata = train_data[train_data["income"] < 1].sample(n=n, replace=True)
    else:
        subdata = train_data[train_data["income"] >= 1].sample(n=n, replace=True)
    te_array = subdata["price"] * gamma_fn(subdata) / (subdata["demand"])
    if stats == "mean":
        return np.mean(te_array)
    elif stats == "median":
        return np.median(te_array)
    elif isinstance(stats, int):
        return np.percentile(te_array, stats)
```

```python
# Get the estimate and range of true treatment effect
truth_te_estimate = np.apply_along_axis(true_te, 1, X_test, 1000, "mean")  # estimate
truth_te_upper = np.apply_along_axis(true_te, 1, X_test, 1000, 95)  # upper level
truth_te_lower = np.apply_along_axis(true_te, 1, X_test, 1000, 5)  # lower level
```

## Parametric heterogeneity
First of all, we can try to learn a **linear projection of the treatment effect** assuming a polynomial form of $\theta(X)$. We use the `LinearDML` estimator. Since we don't have any priors on these models, we use a generic gradient boosting tree estimators to learn the expected price and demand from the data.

```python
# Get log_T and log_Y
log_T = np.log(T)
log_Y = np.log(Y)
```

```python
# Train EconML model
est = LinearDML(
    model_y=GradientBoostingRegressor(),
    model_t=GradientBoostingRegressor(),
    featurizer=PolynomialFeatures(degree=2, include_bias=False),
)
est.fit(log_Y, log_T, X=X, W=W, inference="statsmodels")
# Get treatment effect and its confidence interval
te_pred = est.effect(X_test)
te_pred_interval = est.effect_interval(X_test)
```

```python
# Compare the estimate and the truth
plt.figure(figsize=(10, 6))
plt.plot(X_test.flatten(), te_pred, label="Sales Elasticity Prediction")
plt.plot(X_test.flatten(), truth_te_estimate, "--", label="True Elasticity")
plt.fill_between(
    X_test.flatten(),
    te_pred_interval[0],
    te_pred_interval[1],
    alpha=0.2,
    label="90% Confidence Interval",
)
plt.fill_between(
    X_test.flatten(),
    truth_te_lower,
    truth_te_upper,
    alpha=0.2,
    label="True Elasticity Range",
)
plt.xlabel("Income")
plt.ylabel("Songs Sales Elasticity")
plt.title("Songs Sales Elasticity vs Income")
plt.legend(loc="lower right")
```

From the plot above, it's clear to see that the true treatment effect is a **nonlinear** function of income, with elasticity around -1.75 when income is smaller than 1 and a small negative value when income is larger than 1. The model fits a quadratic treatment effect, which is not a great fit. But it still captures the overall trend: the elasticity is negative and people are less sensitive to the price change if they have higher income.

```python
# Get the final coefficient and intercept summary
est.summary()
```

Coefficient Results

      	     point_estimate	 stderr	  zstat	  pvalue	 ci_lower	 ci_upper

  income	        2.467	      0.073	 33.765	    0.0	    2.347	    2.587

  income^2	      -0.45	      0.025	 -18.147	   0.0	   -0.491	   -0.409

CATE Intercept Results

         	        point_estimate	 stderr	  zstat	  pvalue	 ci_lower	 ci_upper

  cate_intercept	     -3.041	      0.046	 -66.745	   0.0	   -3.116	   -2.967

A linear parametric conditional average treatment effect (CATE) model was fitted:
$Y = \Theta(X)\cdot T + g(X, W) + \epsilon$
where for every outcome $i$ and treatment $j$ the CATE $\Theta_{ij}(X)$ has the form:
$\Theta_{ij}(X) = \phi(X)' coef_{ij} + cate\_intercept_{ij}$
where $\phi(X)$ is the output of the `featurizer` or $X$ if `featurizer`=None. Coefficient Results table portrays the $coef_{ij}$ parameter vector for each outcome $i$ and treatment $j$. Intercept Results table portrays the $cate\_intercept_{ij}$ parameter.

`LinearDML` estimator can also return the summary of the coefficients and intercept for the final model, including point estimates, p-values and confidence intervals. From the table above, we notice that $income$ has positive effect and ${income}^2$ has negative effect, and both of them are statistically significant.

## Nonparametric Heterogeneity
Since we already know the true treatment effect function is nonlinear, let us fit another model using `CausalForestDML`, which assumes a fully **nonparametric estimation of the treatment effect**.

```python
# Train EconML model
est = CausalForestDML(
    model_y=GradientBoostingRegressor(), model_t=GradientBoostingRegressor()
)
est.fit(log_Y, log_T, X=X, W=W, inference="blb")
# Get treatment effect and its confidence interval
te_pred = est.effect(X_test)
te_pred_interval = est.effect_interval(X_test)
```

```python
# Compare the estimate and the truth
plt.figure(figsize=(10, 6))
plt.plot(X_test.flatten(), te_pred, label="Sales Elasticity Prediction")
plt.plot(X_test.flatten(), truth_te_estimate, "--", label="True Elasticity")
plt.fill_between(
    X_test.flatten(),
    te_pred_interval[0],
    te_pred_interval[1],
    alpha=0.2,
    label="90% Confidence Interval",
)
plt.fill_between(
    X_test.flatten(),
    truth_te_lower,
    truth_te_upper,
    alpha=0.2,
    label="True Elasticity Range",
)
plt.xlabel("Income")
plt.ylabel("Songs Sales Elasticity")
plt.title("Songs Sales Elasticity vs Income")
plt.legend(loc="lower right")
```

We notice that this model fits much better than the `LinearDML`, the 90% confidence interval correctly covers the true treatment effect estimate and captures the variation when income is around 1. Overall, the model shows that people with low income are much more sensitive to the price changes than higher income people.

# Understand Treatment Effects with EconML
EconML includes interpretability tools to better understand treatment effects. Treatment effects can be complex, but oftentimes we are interested in simple rules that can differentiate between users who respond positively, users who remain neutral and users who respond negatively to the proposed changes.

The EconML `SingleTreeCateInterpreter` provides interperetability by training a single decision tree on the treatment effects outputted by the any of the EconML estimators. In the figure below we can see in dark red users respond strongly to the discount and the in white users respond lightly to the discount.

```python
intrp = SingleTreeCateInterpreter(include_model_uncertainty=True, max_depth=2, min_samples_leaf=10)
intrp.interpret(est, X_test)
plt.figure(figsize=(25, 5))
intrp.plot(feature_names=X.columns, fontsize=12)
```

# Make Policy Decision with EconML
We want to make policy decisions to maximum the **revenue** instead of the demand. In this scenario,

\begin{align}
Rev & = Y \cdot T \\
    & = \exp^{log(Y)} \cdot T\\
    & = \exp^{(\theta(X) \cdot log(T) + f(X,W) + \epsilon)} \cdot T \\
    & = \exp^{(f(X,W) + \epsilon)} \cdot T^{(\theta(X)+1)}
\end{align}

With the decrease of price, revenue will increase only if $\theta(X)+1<0$. Thus, we set `sample_treatment_cast=-1` here to learn **what kinds of customers we should give a small discount to maximum the revenue**.

The EconML library includes policy interpretability tools such as `SingleTreePolicyInterpreter` that take in a treatment cost and the treatment effects to learn simple rules about which customers to target profitably. In the figure below we can see the model recommends to give discount for people with income less than $0.985$ and give original price for the others.

```python
intrp = SingleTreePolicyInterpreter(risk_level=0.05, max_depth=2, min_samples_leaf=1, min_impurity_decrease=0.001)
intrp.interpret(est, X_test, sample_treatment_costs=-1, treatment_names=["Discount", "No-Discount"])
plt.figure(figsize=(25, 5))
intrp.plot(feature_names=X.columns, fontsize=12)
```

Now, let us compare our policy with other baseline policies! Our model says which customers to give a small discount to, and for this experiment, we will set a discount level of 10% for those users. Because the model is misspecified we would not expect good results with large discounts. Here, because we know the ground truth, we can evaluate the value of this policy.

```python
# define function to compute revenue
def revenue_fn(data, discount_level1, discount_level2, baseline_T, policy):
    policy_price = baseline_T * (1 - discount_level1) * policy + baseline_T * (1 - discount_level2) * (1 - policy)
    demand = demand_fn(data, policy_price)
    rev = demand * policy_price
    return rev
```

```python
policy_dic = {}
# our policy above
policy = intrp.treat(X)
policy_dic["Our Policy"] = np.mean(revenue_fn(train_data, 0, 0.1, 1, policy))

## previous strategy
policy_dic["Previous Strategy"] = np.mean(train_data["price"] * train_data["demand"])

## give everyone discount
policy_dic["Give Everyone Discount"] = np.mean(revenue_fn(train_data, 0.1, 0, 1, np.ones(len(X))))

## don't give discount
policy_dic["Give No One Discount"] = np.mean(revenue_fn(train_data, 0, 0.1, 1, np.ones(len(X))))

## follow our policy, but give -10% discount for the group doesn't recommend to give discount
policy_dic["Our Policy + Give Negative Discount for No-Discount Group"] = np.mean(revenue_fn(train_data, -0.1, 0.1, 1, policy))

## give everyone -10% discount
policy_dic["Give Everyone Negative Discount"] = np.mean(revenue_fn(train_data, -0.1, 0, 1, np.ones(len(X))))
```

```python
# get policy summary table
res = pd.DataFrame.from_dict(policy_dic, orient="index", columns=["Revenue"])
res["Rank"] = res["Revenue"].rank(ascending=False)
res
```

      Revenue
      Rank

      Our Policy
      14.686241
      2.0

      Previous Strategy
      14.349342
      4.0

      Give Everyone Discount
      13.774469
      6.0

      Give No One Discount
      14.294606
      5.0

      Our Policy + Give Negative Discount for No-Discount Group
      15.564411
      1.0

      Give Everyone Negative Discount
      14.612670
      3.0

**We beat the baseline policies!** Our policy gets the highest revenue except for the one raising the price for the No-Discount group. That means our currently baseline price is low, but the way we segment the user does help increase the revenue!

# Conclusions

In this notebook, we have demonstrated the power of using EconML to:

* Estimate the treatment effect correctly even the model is misspecified
* Interpret the resulting individual-level treatment effects
* Make the policy decision beats the previous and baseline policies

To learn more about what EconML can do for you, visit our [website](https://aka.ms/econml), our [GitHub page](https://github.com/microsoft/EconML) or our [documentation](https://econml.azurewebsites.net/).

notebooks/CustomerScenarios/Case Study - Customer Segmentation at An Online Media Company.ipynb


# Lambda School Data Science Module 143

## Introduction to Bayesian Inference

!['Detector! What would the Bayesian statistician say if I asked him whether the--' [roll] 'I AM A NEUTRINO DETECTOR, NOT A LABYRINTH GUARD. SERIOUSLY, DID YOUR BRAIN FALL OUT?' [roll] '... yes.'](https://imgs.xkcd.com/comics/frequentists_vs_bayesians_2x.png)

*[XKCD 1132](https://www.xkcd.com/1132/)*

## Prepare - Bayes' Theorem and the Bayesian mindset

Bayes' theorem possesses a near-mythical quality - a bit of math that somehow magically evaluates a situation. But this mythicalness has more to do with its reputation and advanced applications than the actual core of it - deriving it is actually remarkably straightforward.

### The Law of Total Probability

By definition, the total probability of all outcomes (events) if some variable (event space) $A$ is 1. That is:

$$P(A) = \sum_n P(A_n) = 1$$

The law of total probability takes this further, considering two variables ($A$ and $B$) and relating their marginal probabilities (their likelihoods considered independently, without reference to one another) and their conditional probabilities (their likelihoods considered jointly). A marginal probability is simply notated as e.g. $P(A)$, while a conditional probability is notated $P(A|B)$, which reads "probability of $A$ *given* $B$".

The law of total probability states:

$$P(A) = \sum_n P(A | B_n) P(B_n)$$

In words - the total probability of $A$ is equal to the sum of the conditional probability of $A$ on any given event $B_n$ times the probability of that event $B_n$, and summed over all possible events in $B$.

### The Law of Conditional Probability

What's the probability of something conditioned on something else? To determine this we have to go back to set theory and think about the intersection of sets:

The formula for actual calculation:

$$P(A|B) = \frac{P(A \cap B)}{P(B)}$$

We can see how this relates back to the law of total probability - multiply both sides by $P(B)$ and you get $P(A|B)P(B) = P(A \cap B)$ - replaced back into the law of total probability we get $P(A) = \sum_n P(A \cap B_n)$.

This may not seem like an improvement at first, but try to relate it back to the above picture - if you think of sets as physical objects, we're saying that the total probability of $A$ given $B$ is all the little pieces of it intersected with $B$, added together. The conditional probability is then just that again, but divided by the probability of $B$ itself happening in the first place.

### Bayes Theorem

Here is is, the seemingly magic tool:

$$P(A|B) = \frac{P(B|A)P(A)}{P(B)}$$

In words - the probability of $A$ conditioned on $B$ is the probability of $B$ conditioned on $A$, times the probability of $A$ and divided by the probability of $B$. These unconditioned probabilities are referred to as "prior beliefs", and the conditioned probabilities as "updated."

Why is this important? Scroll back up to the XKCD example - the Bayesian statistician draws a less absurd conclusion because their prior belief in the likelihood that the sun will go nova is extremely low. So, even when updated based on evidence from a detector that is $35/36 = 0.972$ accurate, the prior belief doesn't shift enough to change their overall opinion.

There's many examples of Bayes' theorem - one less absurd example is to apply to [breathalyzer tests](https://www.bayestheorem.net/breathalyzer-example/). You may think that a breathalyzer test that is 100% accurate for true positives (detecting somebody who is drunk) is pretty good, but what if it also has 8% false positives (indicating somebody is drunk when they're not)? And furthermore, the rate of drunk driving (and thus our prior belief)  is 1/1000.

What is the likelihood somebody really is drunk if they test positive? Some may guess it's 92% - the difference between the true positives and the false positives. But we have a prior belief of the background/true rate of drunk driving. Sounds like a job for Bayes' theorem!

$$
\begin{aligned}
P(Drunk | Positive) &= \frac{P(Positive | Drunk)P(Drunk)}{P(Positive)} \\
&= \frac{1 \times 0.001}{0.08} \\
&= 0.0125
\end{aligned}
$$

In other words, the likelihood that somebody is drunk given they tested positive with a breathalyzer in this situation is only 1.25% - probably much lower than you'd guess. This is why, in practice, it's important to have a repeated test to confirm (the probability of two false positives in a row is $0.08 * 0.08 = 0.0064$, much lower), and Bayes' theorem has been relevant in court cases where proper consideration of evidence was important.

## Live Lecture - Deriving Bayes' Theorem, Calculating Bayesian Confidence

Notice that $P(A|B)$ appears in the above laws - in Bayesian terms, this is the belief in $A$ updated for the evidence $B$. So all we need to do is solve for this term to derive Bayes' theorem. Let's do it together!

```python
# Activity 2 - Use SciPy to calculate Bayesian confidence intervals
# https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.bayes_mvs.html#scipy.stats.bayes_mvs
```

```python
import scipy.stats as stats
import numpy as np
```

```python
# Frequentist approach (from yesterday)
def confidence_interval(data, confidence=0.95):
  """
  Calculate a confidence interval around a sample mean for given data.
  Using t-distribution and two-tailed test, default 95% confidence.

  Arguments:
    data - iterable (list or numpy array) of sample observations
    confidence - level of confidence for the interval

  Returns:
    tuple of (mean, lower bound, upper bound)
  """
  data = np.array(data)
  mean = np.mean(data)
  n = len(data)
  stderr = stats.sem(data)
  interval = stderr * stats.t.ppf((1 + confidence) / 2., n - 1)
  return (mean, mean - interval, mean + interval)

```

```python
size =coinflips = np.random.binomial(n=1, p=0.5, size =100)
```

```python
confidence_interval(coinflips), stats.bayes_mvs(coinflips)
```

    ((0.52, 0.4203691469585294, 0.6196308530414707),
     (Mean(statistic=0.52, minmax=(0.43662898196255534, 0.6033710180374446)),
      Variance(statistic=0.2573195876288659, minmax=(0.20255593542955683, 0.32396091281978656)),
      Std_dev(statistic=0.5059610993316946, minmax=(0.4500621461860093, 0.5691756432067228))))

```python
treatment_group = np.random.binomial(n=1, p=0.65, size = 100)
nontreated_group = np.random.binomial(n=1, p=0.4, size =100)

import pandas as pd
df = pd.DataFrame((treatment_group, nontreated_group), index = None)
```

```python
df
```

      0
      1
      2
      3
      4
      5
      6
      7
      8
      9
      ...
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99

      0
      1
      0
      0
      0
      0
      0
      1
      1
      1
      1
      ...
      0
      0
      1
      0
      1
      1
      0
      1
      1
      0

      1
      1
      0
      1
      1
      1
      0
      0
      1
      0
      1
      ...
      0
      0
      1
      0
      1
      0
      1
      0
      1
      0

2 rows × 100 columns

## Assignment - Code it up!

Most of the above was pure math - write Python code to reproduce the results. This is purposefully open ended - you'll have to think about how you should represent probabilities and events. You can and should look things up, and as a stretch goal - refactor your code into helpful reusable functions!

If you're unsure where to start, check out [this blog post of Bayes theorem with Python](https://dataconomy.com/2015/02/introduction-to-bayes-theorem-with-python/) - you could and should create something similar!

Stretch goal - apply a Bayesian technique to a problem you previously worked (in an assignment or project work) on from a frequentist (standard) perspective.

```python
# TODO - code!
```

```python
def bayes(BcA,A,B):
    return (A*BcA)/B
```

```python
bayes(1,.001,0.08)
```

    0.0125

```python
#drunk driving example
#double check the breathelyze
A = 0.08*.08
A
```

    0.0064

```python
bayes(1,0.001,0.0064)
```

    0.15625

### How many breathylyzer tests do you need to be more than 50% sure

```python
def fifty_percent_sure(BcA,A,B):
    count = 1
    while bayes(BcA,A,B) < .5:

        B = B**2
        count +=1

    print("you need to test "+str(count)+" times to be more than 50% sure of the results")
```

```python
fifty_percent_sure(1,.001,.07992)
```

    you need to test 3 times to be more than 50% sure of the results

(Sending this to channel as it's a good question - regarding the breathalyzer calculation from the prewatch video).

Technically the 0.08 is rounded - but it's very close to the true value. Overall we can consider P(Positive) = P(Positive|Drunk)P(Drunk) + P(Positive|Not Drunk)P(Not Drunk) (this is referred to as the "total probability of P(Positive)").

- P(Positive|Drunk)P(Drunk) = 1 * 0.001 = 0.001
- P(Positive|Not Drunk)P(Not Drunk) = 0.08 * 0.999 = 0.07992
- 0.07992 + 0.001 = 0.08092, which is approximately 0.08

```python
def bayes_for_test():
    tp = float(input("what is the true positive rate of your test? \n please use decimals i.e. 0.01\n"))
    gp = float(input("what is the probability of the general public meeting this condition\nplease use decimals i.e. 0.01\n"))
    fp = float(input("What is the false positive rate of the test?\n"))
    s = bayes(tp,gp,tp-1+fp)
    print('the probability of the condition being true, given the positive test is '+str(round(s,2))+'%.')
    fifty_percent_sure(tp,gp,tp-1+fp)
```

```python
bayes_for_test()
```

    what is the true positive rate of your test?
     please use decimals i.e. 0.01
    1
    what is the probability of the general public meeting this condition
    please use decimals i.e. 0.01
    .001
    What is the false positive rate of the test?
    0.07992
    the probability of the condition being true, given the positive test is 0.01%.
    you need to test 3 times to be more than 50% sure of the results

## bayes theorem sampling

```python
import numpy as np
data_coin_flips = np.random.randint(2, size=1000)
np.mean(data_coin_flips)
```

    0.497

```python
bernoulli_flips = np.random.binomial(n=1, p=.5, size =1000)
np.mean(bernoulli_flips)
```

    0.475

```python
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline
sns.set(style='ticks', palette='Set2')

params = np.linspace(0, 1, 100)
p_x = [np.product(stats.bernoulli.pmf(data_coin_flips, p)) for p in params]
p_x =p_x/np.sum(p_x)
plt.plot(params, p_x)

sns.despine()
max(p_x)
```

```python
from IPython.display import Latex

```

```python

```

```python

```

```python

```

```python

```

### breast cancer & BRCA
- (1/8) women will be diagonosed with breast cancer
- 0.25% of people carry a mutuated BRCA gene
- ~60% of BRCA1 carriers develop cancer by 70
- 45% of BRCA2 carriers develop cancer by 70
- for convenience, i will use 52.5% of BRCA carriers will develop cancer

What Percent of women diagnosed with cancer have the mutation?

\begin{align}
P(BRCA | cancer) &= \frac {P(cancer | BRCA) \times P(BRCA)}{P(cancer)}  \\
P(BRCA) &= 0.0025 \\
P(Cancer) &= (1/8) = .125 \\
P(Cancer | BRCA) &= .525 \\
P(BRCA | cancer) &= \frac {.525 \times 0.0025} {0.125} \\
P(BRCA | cancer) &= 0.0105
\\
\\
\end{align}

https://www.nationalbreastcancer.org/what-is-brca

https://www.nationalbreastcancer.org/genetic-testing-for-breast-cancer

\\
P(NoCancer) &= (7/8) = .875 \\
P(No BRCA) &= .9975 \\
P(No Cancer | No BRCA) &= P(No Cancer * No BRCA)/P(NoBRCA) \\
P(No Cancer | No BRCA) &= \frac{P(No BRCA | No Cancer) \times P(NoCancer)}{P(NoBRCA)}\\
P(Cancer | BRCA) &= .525 \\
P(NoCancer | BRCA) &= .475 \\
P(Cancer | BRCA) &= P(Cancer * BRCA)/P(BRCA)\\
\end{align}

```python

```


##### Copyright 2018 The TF-Agents Authors.

# Introduction

Reinforcement learning (RL) is a framework for agents that learn to take actions in an environment so as to maximize a reward. The two main components are the environment, which represents the problem to be solved (like a dataset in supervised learning), and the agent, which represents the learning algorithm.

The agent and environment continuously interact with each other. At each time step, the agent takes an action on the environment based on its *policy* $\pi(a_t|s_t)$, where $s_t$ is the current observation* from the environment, and recieves a reward $r_{t+1}$ and the next observation $s_{t+1}$ from the environment. The goal is to improve the policy so as to maximize the sum of rewards (return).

Note: It is important to distinguish between the `state` of the environment and the `observation`, which is the part of the environment `state` that the agent can see, e.g. in a poker game, the environment state consists of the cards belonging to all the players and the community cards, but the agent can observe only its own cards and a few community cards. In most literature, these terms are used interchangeably and observation is also denoted as $s$.

TODO(kbanoop): Replace with our own image.

This is a very general framework and can model a variety of sequential decision making problems such as games, robotics etc.

# The Cartpole Environment

TODO(kbanoop): replace with our own gif.

The Cartpole environment is one of the most well known classic reinforcement learning problems ( the *"Hello, World!"* of RL). A pole is attached to a cart, which can move along a frictionless track. The pole starts upright and the goal is to prevent it from falling over by controlling the cart.

*   The observation from the environment $s_t$ is a 4D vector representing the position and velocity of the cart, and the angle and angular velocity of the pole.
*   The agent can control the system by taking one of 2 actions $a_t$: push the cart right (+1) or left (-1).
*   A reward $r_{t+1} = 1$ is provided for every timestep that the pole remains upright. The episode ends when one of the following is true:
  * the pole tips over some angle limit
  * the cart moves outside of the world edges
  * 200 time steps pass.

The goal of the agent is to learn a policy $\pi(a_t|s_t)$ so as to maximize the sum of rewards in an episode $\sum_{t=0}^{T} \gamma^t r_t$. Here $\gamma$ is a discount factor in $[0, 1]$ that discounts future rewards relative to immediate rewards. This parameter helps us focus the policy, making it care more about the short or long term.

# The DQN Agent

The  [DQN (Deep Q-Network) algorithm](https://storage.googleapis.com/deepmind-media/dqn/DQNNaturePaper.pdf) was developed by Deepmind in 2015. It was able to solve a wide range of Atari games (some to superhuman level) by combining reinforcement learning and deep neural networks at scale. The algorithm was developed by enhancing a classic RL algorithm called Q-Learning with  deep neural networks and a technique called *experience replay*.

## Q-Learning

Q-Learning is based on the notion of a Q function. The Q function (a.k.a the state-action value function) of a policy $\pi$, $Q^{\pi}(s, a)$, measures the expected return or sum of rewards obtained from state $s$ by taking action $a$ first and following policy $\pi$ thereafter. We define the optimal Q function $Q^*(s, a)$ as the maximum return that can be obtained starting from observation $s$, taking action $a$ and following the optimal policy thereafter. The optimal Q function obeys the following *Bellman* optimality equation:

$\begin{equation}
Q^*(s, a) = \mathbb{E}\left[ r + \gamma \max_{a'} Q^*(s', a')\right]
\end{equation}$

This means that the maximum return from state $s$ and action $a$ is the sum of the immediate reward $r$  and the return obtained by following the optimal policy therafter until the end of the episode (i.e. the maximum reward from the next state $s'$). The expectation is computed both over the distribution of immediate rewards $r$ and possible next states $s'$.

The basic idea behind Q-Learning is to use the Bellman optimality equation as an iterative update $Q_{i+1}(s, a) \leftarrow \mathbb{E}\left[ r + \gamma \max_{a'} Q_{i}(s', a')\right]$, and it can be shown that this converges to the optimal $Q$ function, i.e. $Q_i \rightarrow Q^*$ as $i \rightarrow \infty$ (see e.g. [the DQN paper](https://www.cs.toronto.edu/~vmnih/docs/dqn.pdf)).

## Tabular Q-Learning

TODO

## Deep Q-Learning

For most problems, it is impractical to represent the $Q$ function as a table containing values for each combination of $s$ and $a$. Instead, we train a function approximator, such as a neural network with parameters $\theta$, to estimate the Q values, i.e. $Q(s, a; \theta) \approx Q^*(s, a)$. This can done by minimizing the following loss at each step $i$:

$\begin{equation}L_i(\theta_i) = \mathbb{E}_{s, a, r, s'\sim \rho(.)} \left[ (y_i - Q(s, a; \theta_i))^2 \right]\end{equation}$ where $y_i = r +  \gamma \max_{a'} Q(s', a'; \theta_{i-1})$

Here, $y_i$ is called the TD (temporal difference) target, and $y_i - Q$ is called the TD error. $\rho$ represents the behaviour distribution, the distribution over transitions $\{s, a, r, s'\}$ collected from the environment.

Note that the parameters from the previous iteration $\theta_{i-1}$ are fixed and not updated. In practice we use a snapshot of the network parameters from a few iterations ago instead of the last iteration. This copy is called the *target network*.

Q-Learning is an *off-policy* algorithm that learns about the greedy policy $a = \max_{a} Q(s, a; \theta)$ while using a different behaviour policy for acting in the environment/collecting data. This behaviour policy is usually an $\epsilon$-greedy policy that selects the greedy action with probability $1-\epsilon$ and a random action with probability $\epsilon$, to ensure good coverage of the state-action space.

## Experience Replay

To avoid computing the full expectation in the DQN loss, we can minimize it using stochastic gradient descent. If the loss is computed using just the last transition $\{s, a, r, s'\}$, this reduces to standard Q learning.

The Atari DQN work introduced a technique called Experience Replay to make the network updates more stable. At each time step of data collection, the transitions are added to a circular buffer called the *replay buffer*. Then during training, instead of using just the latest transition to compute the loss and its gradient, we compute them using a mini-batch of transitions sampled from the replay buffer. This has two advantages: better data efficiency by reusing each transition in many updates, and better stability using uncorrelated transitions in a batch.

## Pseduocode

TODO

# DQN on Cartpole in TF-Agents

TF-Agents provides all the components necessary to train a DQN agent, such as the agent itself, environment, policies, networks, replay buffers, data collection loops, metrics etc. These components are implemented as python functions or TensorFlow graph ops, and we also have wrappers for converting between them. Additionally, most components support eager mode which allow us to use TF graph ops in imperative mode.

There are various ways to put these modules together. e.g. having most components as graph ops makes the code more efficient, but can be harder to read and debug, whereas moving things out of the graph into pure python might make it easier to debug at the cost of some computational efficiency.

Now, we will walk you through 3 ways of training DQN:

1. Python/OOG Mode Colab link (easiest to debug)
2. Eager Mode Colab link
3. Graph Mode Colab link (most efficient)


Massimo Nocentini

April 28, 2016: refactoring

April 11, 2016: breaking the cipher

April 8, 2016: big-bang

Abstract

Vigenere cipher cryptoanalysis.

# Vigenere cipher cryptoanalysis

This notebook studies the cryptoanalysis of the [Vigenere][wiki] cipher, which is polyalphabetic, namely two occurrences  $a_{1}, a_{2}$ of character $a$ belonging to the plaintext are associated with occurrences $c_{1}, c_{2}$ in the ciphertext, such that $c_{1}\neq c_{2}$ with very high probability.

Our implementation closely follows a class lecture given by Prof. [Orazio Puglisi][puglisi] within Cryptography course at the University of Florence. In order to fully understand the last part, where the concept of *mutual coincidence index* is crucial, we rest on the explanation at pag. 20 of [this notes][notes].

[wiki]:https://en.wikipedia.org/wiki/Vigen%C3%A8re_cipher
[puglisi]:http://web.math.unifi.it/users/puglisi/
[notes]:http://iml.univ-mrs.fr/~ritzenth/cours/crypto.general.pdf

### physical shifting ciphers

A crypto ruler: ![][crypto-ruler]

[crypto-ruler]: https://upload.wikimedia.org/wikipedia/commons/f/fa/Cryptographic_sliding_rule-IMG_0533.jpg "Crypto-ruler"

A Swiss disk cipher:![][swiss-disk]

[swiss-disk]:https://upload.wikimedia.org/wikipedia/en/3/3f/Confederate_cipher_disk.png "swiss disk"

```python
import itertools
from itertools import *
from copy import copy, deepcopy
from heapq import *
from random import *
import matplotlib.pyplot as plt
from collections import Counter
from sympy import *

init_printing()

%matplotlib inline
plt.rcParams['figure.figsize'] = (10.0, 10.0)
```

---

### alphabet

Let $\mathcal{A}$ be our alphabet composed of standard English characters plus the `space` character. It will be sufficient to encode simple and (not so quite) short messages. To define it as a group $\frac{\mathbb{Z}}{n\mathbb{Z}}$ in the Python language we use a `dict` object, which can be reversed because it is a bijection.

```python
def make_alphabet_entry(i):
    alpha = i + ord('a')
    return chr(alpha),i

A = dict(map(make_alphabet_entry, range(26)))
A.update({' ':26})
inverse_A = {v:k for k,v in A.items()}

A
```

    {' ': 26,
     'a': 0,
     'b': 1,
     'c': 2,
     'd': 3,
     'e': 4,
     'f': 5,
     'g': 6,
     'h': 7,
     'i': 8,
     'j': 9,
     'k': 10,
     'l': 11,
     'm': 12,
     'n': 13,
     'o': 14,
     'p': 15,
     'q': 16,
     'r': 17,
     's': 18,
     't': 19,
     'u': 20,
     'v': 21,
     'w': 22,
     'x': 23,
     'y': 24,
     'z': 25}

### string encoding and the *plaintext*

We define a function `encode` that consumes a string and produces a list of integer in our field. In parallel, function `decode` goes backwards, it consumes a list of integers and return a string. Such functions are useful in order to use the cipher and analyze it using `str` objects instead of the coded version using lists of integers.

```python
def encode(s, alphabet=A): return list(map(lambda i: alphabet[i], s))

def decode(e, inverse_alphabet=inverse_A): return "".join(map(lambda i: inverse_alphabet[i], e))
```

The following plaintext is a prose taken from [here]; before using it we have to swipe out punctuation marks:

[here]:http://www.bartleby.com/209/2.html

```python
def clean_text(text):
    remove_chars = [',', '.', ';', ':', '-', '(', ')', "'", '"']
    for rc in remove_chars: text = text.replace(rc, '')
    text = text.replace('\n', ' ')

    return "".join(filter(lambda c: not c.isdigit(), text))

with open('rest_plain_text.txt', 'r') as f:
    plain_text = clean_text(f.read().lower())

encoded_plain_text = encode(plain_text)

chunk = 500
"{}...".format(plain_text[:chunk])
```

    'the attraction of medieval literature comes perhaps more strongly from some other countries than from england in france and provence in germany and iceland there were literary adventurers more daring and achievements more distinguished it was not in england that the most wonderful things were produced there is nothing in old english that takes hold of the mind with that masterful and subduing power which still belongs to the lyrical stanzas of the troubadours and minnesingers to welsh romance or...'

With the following assert we ensure that function `decode` is the inverse of function `encode`:

```python
assert decode(encode(plain_text)) == plain_text
```

### key

Let $\textbf{k}=(k_{0},\ldots,k_{m-1}) \in \left(\frac{\mathbb{Z}}{n\mathbb{Z}}\right)^{m}$ be a *key* of length $m\in\mathbb{N}$. In order to have a meaningful analysis we build a function `generate_random_key` which generate a random key of random length, keeping it safe...we will uncover it only at the end to check our work:

```python
def generate_random_key(given_key=None, required_length=None, max_length=None, alphabet=A):

    if given_key is not None: return given_key

    if required_length is None and max_length is None: max_length = len(alphabet)

    # the minimum length of the key is 3 to build interesting cases
    length = required_length if required_length else randint(3, max_length)
    key = [0] * length
    # -1 in the following max limit because it is inclusive in the sense of `randint`.
    for i in range(length):
        key[i] = randint(0, len(alphabet)-1)
    return key

#key = encode("ericsmullyan")
secret_key = generate_random_key(required_length=17)
```

### encryption and the *ciphertext*

Now we are in the position to define `encrypt` and `decrypt` functions, both of them consumes an *encoded* message, namely a list of integers, and a key. The encryption work by repeating the key as long as necessary to match the same length of the input $\textbf{x}=(x_{0},\ldots,x_{h})$, where $h > m-1$ otherwise the cipher is a *OneTimePad* which is unbreakable,
$$(\underbrace{x_{0},\ldots,x_{m-1}}_{k_{0},\ldots,k_{m-1}}
\underbrace{x_{m},\ldots,x_{m-1}}_{k_{0},\ldots,k_{m-1}}
\ldots
\underbrace{x_{lm},\ldots,x_{h}}_{k_{0},\ldots,k_{h \mod m}}) =
(y_{0},\ldots,y_{h}) = \textbf{y}$$
truncating the very right block to match plaintext suffix. At last, the ciphertext $\textbf{y}$ is obtained by addition modulo $n$ of corresponding symbols, where $n$ is the length of the alphabet. Decryption follows the same scheme, using modular subtraction.

```python
def encrypt(message, key, alphabet=A):
    n = len(alphabet)
    return [(p+v)%n for p,v in zip(message, cycle(key))]

def decrypt(cipher, key, alphabet=A):
    n = len(alphabet)
    return [(c-v)%n for c,v in zip(cipher, cycle(key))]
```

the following is the *ciphertext* produced by the application of function `encrypt` to the plaintext:

```python
cipher_text = encrypt(encoded_plain_text, secret_key)
'{}...'.format(decode(cipher_text)[:chunk])
```

    'thbeczxtx vubwyic mbikkzchxnugnpvqurbeeuqgoxrqeqzjp mlwgfwvnlpszgy oomxxqsibkqjqeiairntongydvdyplt mgxendqcthbekbrejlxb akibvvqrbposighxgeorctbbxkflwlcfyndxyjkvgwtgcsijcqeryw fefrbpeh clp mlwgfhcnfpsnjlyxacengaioakvdnvmlb dfxvoriqfutsmycq wyxbtsvwfplswefyndxyjgxbpegl xqnxwokigxjwhxvtwwemxweojbvvq reqrirbbrexnufrqpekzuighxolaegtknepjlgqznxtahjuflqhab tirbb mfsff kpebevjrujasqjtlynwyppnaswauiklbvsyaobhvrabxstfqnffghlpsfirixthbendvkzynlfbzhwasxthfxjaxvcbc vaouoxbgrfwjkzanqckgeoxbzsbsbndvipijan jbuv...'

Assuming to know the `secret_key`, we ensure that function `decrypt` is the inverse of function `encrypt`:

```python
assert decode(decrypt(cipher_text, secret_key)) == plain_text
```

### coincidence index

Let $I_{c}$ be the *coincidence index* of a sequence $\alpha$, over an alphabet $A$, defined as
$$ I_{c}(\alpha) = \sum_{i=0}^{n}{\frac{f_{a_{i}}^{(\alpha)}(f_{a_{i}}^{(\alpha)}-1)}{m(m-1)}} $$ where $n$ is the length of the alphabet, $m$ is the length of $\alpha$ and $f_{a_{i}}^{^{(\alpha)}}$ is the frequency of symbol $a_{i}\in A$, namely the number of occurrences of $a_{i}$, in $\alpha$. In other words, $I_{c}$ is the probability to sample two occurrences of the same symbol $a$, forall $a\in A$, from the sequence $\alpha$.

Index $I_{c}$ is invariant to shifting by the same constant $v$. Let $\alpha,\beta$ be two sequences of integers of length $l$ such that $\beta_{i} \equiv_{n} \alpha_{i} + v$, forall $i\in\lbrace0,\ldots,l\rbrace$; moreover, let $q_{\gamma_{i}}^{(\gamma)}$ be the probability to sample *two* occurrences of $\gamma_{i}$ from a sequence $\gamma$, we can state the relations
$$
    I_{c}(\beta) = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}}^{(\beta)}}
                = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}-v}^{(\alpha)}}
                = \sum_{\hat{a}_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{\hat{a}_{i}}^{(\alpha)}}
                = I_{c}(\alpha)
$$
where $\hat{a}_{i}\equiv_{n}a_{i}-v$, proving the invariance of $I_{c}$ when a sequence is produced by another one shifted by a constant $v\in\frac{\mathbb{Z}}{n\mathbb{Z}}$.

```python
def frequencies(lst, alphabet=A, inverse_alphabet=inverse_A):
    """ Produces a `dict` counting occcurrences of each object of the alphabet within the iterable.

    `frequencies` consumes an iterable $lst$ and an alphabet $A$,
    produces a dictionary of entries $(k,v)$ where $k$ is a character
    in $A$ and $v$ is the number of occurrences of $k$ in $lst$
    """
    counter = Counter(lst)
    return {k:counter[v] for k,v in alphabet.items()} # Counter handles the case of missing key returning 0

def length_from_frequencies(freqs):
    """ Returns the length of the original sequence by summation of symbols frequencies. """
    return sum(freqs.values())

def coincidence_index(freqs, alphabet=A):
    """ Produces the I_{c} relative to frequencies matched against an alphabet. """
    denom = length_from_frequencies(freqs)
    if denom in range(2): return None
    def mapper(a):
        v = freqs[a] if a in freqs else 0
        return v*(v-1)
    return sum(map(mapper, alphabet.keys()))/(denom*(denom-1))

def draw_frequencies_histogram(seq, alphabet=A, y_maxlimit=None, normed=None):

    #plaintext_length = len(plain_text)
    #freqs = [plaintext_frequencies[inverse_A[ia]] for ia in sorted(inverse_A.keys())]
    n, bins, patches = plt.hist(seq, len(alphabet), normed=normed,facecolor='green', alpha=0.5)

    plt.xlabel('alphabet symbols')
    plt.ylabel('frequencies' + (', normed respect: {}'.format(str(normed)) if normed else ''))
    plt.xticks(range(-1, len(alphabet)), sorted(alphabet.keys()))
    if y_maxlimit: plt.ylim([0, y_maxlimit])
    plt.grid(True)
    plt.show()

    return None
```

### frequencies comparison

The following are frequencies of alphabet symbols in the *plaintext*: in the analysis they are used as a `dict` produced by function `frequencies`, moreover it is possible to draw an histogram with the relative function.

```python
plaintext_frequencies = frequencies(encoded_plain_text)
draw_frequencies_histogram(encoded_plain_text, y_maxlimit=1800)
```

```python
plaintext_frequencies
```

    {' ': 1620,
     'a': 571,
     'b': 90,
     'c': 207,
     'd': 272,
     'e': 1001,
     'f': 208,
     'g': 162,
     'h': 422,
     'i': 594,
     'j': 2,
     'k': 46,
     'l': 312,
     'm': 184,
     'n': 624,
     'o': 633,
     'p': 131,
     'q': 8,
     'r': 529,
     's': 490,
     't': 800,
     'u': 158,
     'v': 71,
     'w': 129,
     'x': 13,
     'y': 129,
     'z': 2}

The following histogram shows frequencies in the *ciphertext*: using the same `y_maxlimit` value, we see that they are spread "uniformly" over symbols.

```python
draw_frequencies_histogram(cipher_text, y_maxlimit=1800)
```

A first approach to encryption finishes computing the coincidence indexes of both *plaintext* and *ciphertext*:

```python
print("coincidence index of *plaintext*: {}\ncoincidence index of *ciphertext*: {}".format(
        coincidence_index(plaintext_frequencies), coincidence_index(frequencies(cipher_text))))
```

    coincidence index of *plaintext*: 0.07720140650073147
    coincidence index of *ciphertext*: 0.039775706179144346

### finding the key *length* by spreading

The following set of functions allows us to probe the key *length* by repeatedly fixing a candidate length $l$, then spreading the *ciphertext* in a matrix with $l$ columns, then computing $I_{c}$ of each column and, finally, report $l$ if the majority of $I_{c}$ scores is greater than a threshold (.06 for English).

```python
def spread(message, block_length):
    return [message[i:i+block_length] for i in range(0, len(message), block_length)]

def col(spreaded, c, join=False, joiner=lambda c: ''.join(decode(c))):
    column = [lst[c] if c < len(lst) else None for lst in spreaded]
    ready = list(filter(lambda i: i is not None, column))
    return joiner(ready) if join else ready

def decode_spreaded(spreaded, join_as_str=False):
    decoded_spread = list(map(decode, spreaded))
    return '\n'.join(decoded_spread) if join_as_str else decoded_spread

def analyze(cipher_text, max_key_length=None):
    res = {}
    # we discard the case where the key length equals the
    # length of the cipher text, since it is the case of
    # OneTimePad cipher, which is unbreakable!
    for d in range(2, len(cipher_text) if max_key_length is None else max_key_length + 1):
        spreaded = spread(cipher_text, d)
        res[d] = []
        for c in range(d):
            ci = coincidence_index(frequencies(col(spreaded, c)))
            if ci: res[d].append(ci)

    return res

def guess_key_length(analysis, threshold=0.06):
    candidates = {}
    for k,v in analysis.items():
        cs = list(filter(lambda i: i > threshold, v))
        if cs and len(cs) > ceiling(k/2): candidates[k] = cs
    return candidates
```

here are $I_{c}$ scores witnesses for the candidate key length after spreading the *ciphertext* by columns:

```python
analysis = analyze(cipher_text, max_key_length=20)
guess = guess_key_length(analysis)
guess
```

```python
probing_key_length = 17
```

The following is the *ciphertext* spread over a matrix with a number of column equals the candidate length found in the previous cell.

```python
spreaded = decode_spreaded(spread(cipher_text, probing_key_length))
print("{}\n...\n{}".format("\n".join(spreaded[:10]), "\n".join(spreaded[-3:])))
```

    thbeczxtx vubwyic
     mbikkzchxnugnpvq
    urbeeuqgoxrqeqzjp
     mlwgfwvnlpszgy o
    omxxqsibkqjqeiair
    ntongydvdyplt mgx
    endqcthbekbrejlxb
     akibvvqrbposighx
    georctbbxkflwlcfy
    ndxyjkvgwtgcsijcq
    ...
    thysbisoapbzobsly
    llvektdrnfougrtzx
    espf yd

### mutual coincidence index

Once the length $m$ of key $\textbf{k}$ has been "established", we're left with finding actual key symbols $k_{i}$, for $i\in\{0,\ldots,m-1\}$. In order to fullfil this step, we need another object, which resembles the index of mutual coincidence $I_{c}$ but it is more general, in the sense that sampling occurs on two given sequences instead of the same one. Formally, let $I_{mc}(\alpha, \beta)$ be the *index of mutual coincidence* of sequences $\alpha$ and $\beta$, defined as
$$
I_{mc}(\alpha,\beta) = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}}^{(\alpha)}q_{a_{i}}^{(\beta)}}
$$
where $q_{a_{i}}^{(\nu)}$ is the probability to draw an occurrence of symbol $a_{i}$ in sequence $\nu$.

Let $\eta$ and $\gamma$ be two sequences of length $l$, produced by adding shifts $v_{\alpha}$ and $v_{\beta}$ to sequences $\alpha$ and $\beta$, respectively (formally, $\eta_{i} \equiv_{n} \alpha_{i} + v_{\alpha}$ and $\gamma_{i} \equiv_{n} \beta_{i} + v_{\beta}$, forall $i\in\lbrace0,\ldots,l-1\rbrace$). The inequality
$$
    I_{mc}(\eta,\gamma)
                = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}}^{(\eta)}q_{a_{i}}^{(\gamma)}}
                = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}-v_{\alpha}}^{(\alpha)}q_{a_{i}-v_{\beta}}^{(\beta)}}
                = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}}^{(\alpha)}q_{a_{i}+v_{\alpha}-v_{\beta}}^{(\beta)}}
                \neq \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}}^{(\alpha)}q_{a_{i}}^{(\beta)}}
                = I_{mc}(\alpha,\beta)
$$
holds unless factor $v_{\alpha}-v_{\beta}$ in each subscript. We can define an even more general version of $I_{mc}$ as
$$
    I_{mc}(\eta,\gamma,g)
                = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}-g}^{(\eta)}q_{a_{i}}^{(\gamma)}}
                = \sum_{a_{i}\in\frac{\mathbb{Z}}{n\mathbb{Z}}}{q_{a_{i}}^{(\alpha)}q_{a_{i}+v_{\alpha}-v_{\beta}+g}^{(\beta)}}
$$
where sequence $\eta$ is shifted back by $g\in\mathbb{N}$. Therefore we can state the equality with the usual definition according to
$$
    I_{mc}(\eta,\gamma,g) = I_{mc}(\alpha,\beta) \leftrightarrow v_{\beta}-v_{\alpha}=g
$$
proving the invariance of $I_{mc}$ when two sequences are produced by shifting two other ones by constant values $q,w$ respectively, providing that $g = w-q$.

```python
def mutual_coincidence_index(fst_freqs, snd_freqs, offset=0,
                             alphabet=A, inverse_alphabet=inverse_A):
    fst_len = length_from_frequencies(fst_freqs)
    snd_len = length_from_frequencies(snd_freqs)
    n = len(alphabet)
    return sum(fst_freqs[k] * snd_freqs[inverse_alphabet[(v+offset) % n]]
               for k,v in alphabet.items())/(fst_len * snd_len)
```

Previous generalization allows us to set the basis to break the cipher. It works as follows: for each pair $(\eta,\gamma)$ of different columns in the spread-matrix form of the *ciphertext*, fix a $g\in\frac{\mathbb{Z}}{n\mathbb{Z}}$ to compute $I_{mc}(\eta,\gamma,g)$: if such index value is close to $I_{c}(\nu)$, where $\nu$ is a structured sequence using the English language, namely close to $0.065$, then collect equation $k_{i(\beta)}-k_{i(\alpha)}=g$, where function $i(\nu)$ return the zero-based index of column $\nu$ in the matrix spread. Such equations are important since state difference relations over symbols of the key.

```python
def build_offsets_eqs(cipher_text, key_length, indexed_sym, threshold=.06, alphabet=A):

    n = len(alphabet)
    eqs = {c:{} for c in range(key_length)}
    spreaded = spread(cipher_text, key_length)
    for c,a in itertools.product(range(key_length), repeat=2):

        if a == c: continue

        eqs[c][a]=[]

        for g in range(1,n):
            column_freqs = frequencies(col(spreaded, c))
            another_freqs = frequencies(col(spreaded, a))
            mci = mutual_coincidence_index(column_freqs, another_freqs, g)
            if mci > threshold:
                eqs[c][a].append(tuple([Eq(indexed_sym[a]-indexed_sym[c],g,evaluate=True), mci]))

    return eqs
```

```python
k_sym=IndexedBase('k')

eqs_dict = build_offsets_eqs(cipher_text, key_length=probing_key_length, indexed_sym=k_sym)
```

The following cell report the set of difference equation collected respect the *first* column of the spread matrix: we observe that such set fails to instantiate $k_{6}$ because no likely equation gives a relation for it. On the contrary, it can be the case that collecting equations for a pair of columns with indexes $(c,a)$ yields one or more equations, namely $\{k_{a}-k_{c}=g_{u_{0}},\ldots,k_{a}-k_{c}=g_{u_{v}}\}$, for some $v\in\mathbb{N}$: to properly explore all keys space, we have to consider the product of each list of "equally-likely" equations.

```python
eqs_dict[0]
```

The following function implements the last observation, namely it produces the complete keys space where we've to look for the good one.

```python
def explode_key_space(eqs_dict):

    res = {}
    for c, eq_dict in eqs_dict.items():
        eqs_list = []
        for a, eqs in eq_dict.items():
            if not eqs:
                res[c] = [] # no equations in `eqs` causes the product to be empty as well
                break
            eqs_list.append([[a] + list(eq_pair) for eq_pair in eqs])
        else: # if no empty eqs was found then it is meaningful to cross product them
            res[c] = list(itertools.product(*eqs_list))
    return res
```

```python
eqs_dict_pure = explode_key_space(eqs_dict)
eqs_dict_pure
```

In order to instantiate candidates keys, for each index column $c$ we use the set of equations $\{k_{a}-k_{c}=g_{u}\}$, for some $u\in\mathbb{N}$ and $a\in\{0,\ldots,m-1\}\setminus\{c\}$ as follows: for each equation $k_{a}-k_{c}=g_{u}$, instantiate $k_{c} = s$, for $s\in\frac{\mathbb{Z}}{n\mathbb{Z}}$, and solve it respect $k_{a}$. Therefore for each column index $c$ we have a candidate key if each symbol $k_{i}$ has been instantiated.

```python
def candidate_keys(eqs_dict, indexed_sym, alphabet=A):
    key_length = len(eqs_dict)
    n = len(alphabet)
    candidates=set()
    for c, eqs_tuples in eqs_dict.items():
        for d in range(len(alphabet)):
            for eq_tuple in eqs_tuples:
                key = [indexed_sym[i] for i in range(key_length)]
                key[c] = d
                for a, eq, mci in eq_tuple:
                    subs_eq = eq.subs(indexed_sym[c],d)
                    key[a] = solve(subs_eq, indexed_sym[a])[0]
                    key[a] = key[a] % n

                for k in key:
                    if isinstance(k, Indexed): break
                else:
                    candidates.add(tuple(key)) # `tuple` application to make `key` hashable
    return candidates
```

```python
possible_keys = candidate_keys(eqs_dict_pure, k_sym)
```

The last step to filter out mistaken keys, we use each candidate key to perform a decryption, checking it against frequencies of an arbitrary English prose:

```python
def arbitrary_frequencies(length, filename='rest_plain_text.txt'):
    with open(filename, 'r') as f:
        text = clean_text(f.read().lower())
    text = text[:length]
    return frequencies(encode(text))

def attempt_keys(candidate_keys, cipher_text, threshold=.06, arbitrary_freqs=None):
    sols = set()
    for key in candidate_keys:
        decrypted = decrypt(cipher_text, key)
        freqs = frequencies(decrypted)
        if arbitrary_freqs:
            good = True
            for k,v in arbitrary_freqs.items():
                if 1-abs((freqs[k]-v)/v) < .6: good = False
            if good: sols.add(decode(key))
        else:
            ci = coincidence_index(freqs)
            if ci > threshold: sols.add(decode(key))#sols.add((ci, decode(key)))
    return sols
```

```python
sols = attempt_keys(possible_keys, cipher_text, arbitrary_freqs=arbitrary_frequencies(len(cipher_text)))
```

```python
len(sols), sols
```

    (1, {'aayfcgecxycmojzvy'})

```python
for sol in sols:
    key = sol
    print("key:({})\nplaintext:\n{}\n\n".format(
            key, decode(decrypt(cipher_text, encode(key)))[:chunk]))
```

    key:(aayfcgecxycmojzvy)
    plaintext:
    the attraction of medieval literature comes perhaps more strongly from some other countries than from england in france and provence in germany and iceland there were literary adventurers more daring and achievements more distinguished it was not in england that the most wonderful things were produced there is nothing in old english that takes hold of the mind with that masterful and subduing power which still belongs to the lyrical stanzas of the troubadours and minnesingers to welsh romance or

```python
decode(secret_key)
```

    'aayfcgecxycmojzvy'

**:)**

---

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.


# Sitzung 10

Diese Skripte sind ausschließlich als Zusatz-Material gedacht. Speziell für diejenigen unter Euch, die einen Einblick in das Programmieren gewinnen wollen. Wenn Du es also leid bist repetitive Tätigkeiten auszuführen und das lieber einer Maschine überlassen willst, bist Du hier genau richtig.

 Die Codes sind nicht für die Klausur relevant, genau genommen haben sie mit dem Lehrstuhl für Statistik **rein gar nichts** zu tun.
***

```python
import os
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from ipywidgets import interact, FloatSlider
```

# Tinder und die Maximum-Likelihood Methode

## Anzahl Matches in einer Stunde
Aus einem vergangenem [Notebook](https://github.com/thsis/Statistik-I-II/blob/master/Statistik-II/sitzung-5.ipynb) wissen wir, dass die Anzahl matches innerhalb einer Stunde auf Tinder unter milden Annahmen poissonverteilt ist.

$$X_i: \text{Anzahl matches auf Tinder innerhalb einer Stunde i, mit } X_i \sim Poiss(\lambda)$$

Zur Erinnerung, wir hatten zwei Dinge herausgefunden:
1. wir benötigen einen Erfahrungswert für die durchschnittliche Anzahl matches pro Stunde $\lambda$
2. sobald wir einen Wert für $\lambda$ haben, können wir die Wahrscheinlichkeit verschiedener Ergebnisse $x$ berechnen: $$P(X_i=x_i) = \frac{\lambda^{x_i}}{x_i!} \exp(-\lambda)$$

Was wir damals noch nicht geklärt hatten war, wie schätzen wir dieses $\lambda$ am besten? Denn ohne einen guten Wert für $\lambda$, werden wir keine realistischen Wahrscheinlichkeiten ausrechnen können.

### Was ist das beste $\lambda$ das wir wählen können?

Um diese Frage zu beantworten, müssen wir zuerst ein paar Daten erheben. Nehmen wir an, dass wir dazu eine Woche lang, acht Stunden pro Tag auf Tinder ununterbrochen nach rechts wischen und unsere Beobachtungen notieren.

Wir interessieren uns für:
* die Anzahl Matches innerhalb einer Stunde.
* die Zeit die vergeht, bis wir unser erstes Match kriegen.

Wir erheben also 56 einzelne Datenpunkte und sehen die ersten 5 Ergebnisse dieser Woche auf Tinder in der Tabelle:

```python
path = os.path.join("..", "data", "tinder_ML.csv")
tinder = pd.read_csv(path, index_col=["Zeit"])
tinder.head()
```

      Anzahl Matches
      Zeit bis zum ersten Match

      Zeit

      2018-12-01 18:00:00
      4
      0.034403

      2018-12-01 21:00:00
      0
      NaN

      2018-12-01 23:00:00
      1
      0.177634

      2018-12-02 02:00:00
      2
      0.348853

      2018-12-02 05:00:00
      3
      0.056726

### Wie wahrscheinlich ist es, dass wir genau diese Daten bekommen?

Wir können diese Frage ein wenig umformulieren, um sie bequemer beantworten zu können. Wir suchen nämlich nach der **gemeinsamen Wahrscheinlichkeit** des beobachteten Ereignisses. Oder anders gesagt, wir fragen uns, wie wahrscheinlich war es, dass in der
1. Stunde 4 Matches und in der
2. Stunde 0 Matches und in der
3. Stunde 1 Match und in der
4. Stunde 2 Matches und in der
5. Stunde 3 Matches und in der ...

Wir können das wieder in mathematische Ausdrücke umformulieren:
$$P(X=x_{Daten}) = P(\{X_1=4\} \cap \{X_2=0\} \cap \{X_3=1\} \cap \{X_4=2\} \cap \{X_5=3\} \cap \dots)$$

Und unter den vereinfachenden Annahmen
* $\color{blue}{\textit{identisch verteilte}}$ $X_i$ und
* $\color{orange}{unabhängige}$ $X_i$

Können wir die **gemeinsame Wahrscheinlichkeit** viel einfacher ausdrücken.

Wenn alle $X_i$ $\color{orange}{unabhängig}$ sind, kann man die gemeinsame Wahrscheinlichkeit als Produkt schreiben:

$$
P(X=x_{Daten}) = P_1(\{X_1=4\} \cdot P_2(\{X_2=0\}) \cdot P_3(\{X_3=1\}) \cdot P_4(\{X_4=2\}) \cdot P_5(\{X_5=3\}) \cdot \dots
$$

Und wenn alle $X_i \color{blue}{\textit{ identisch verteilt}}$ sind, haben alle $X_i$ dieselbe Wahrscheinlichkeitsfunktion, also kann man die Subskripte weglassen:

\begin{align}
P(X=x_{Daten}) &= P(\{X_1=4\} \cdot P(\{X_2=0\}) \cdot P(\{X_3=1\}) \cdot P(\{X_4=2\}) \cdot P(\{X_5=3\}) \cdot \dots \\
&= \prod\limits_{i=1}^n P(X_i=x_i)
\end{align}

Jetzt können wir für $P$ eine Verteilungsfunktion einsetzen, die von einem Parameter abhängt (hier: $\lambda$)

$$
P(X=x_{Daten}) = \prod\limits_{i=1}^n P(X_i=x_i) = \prod\limits_{i=1}^n \frac{\lambda^{x_i}}{x_i!} \exp(-\lambda) = \exp(-n\lambda) \prod\limits_{i=1}^n \frac{\lambda^{x_i}}{x_i!}
$$

Und damit haben wir einen Ausdruck, der nur noch von $\lambda$ abhängt, da die $x_i$ bereits eingetroffen sind. Man nennt diesen Ausdruck auch die **Likelihood**-Funktion.

```python
def poisson_likelihood(lam):
    x_data = tinder["Anzahl Matches"].values
    n = len(x_data)
    L = np.exp(-n*lam) * np.prod([lam**xi / np.math.factorial(xi) for xi in x_data], axis=0)
    return L
```

### Zurück zur Frage: welches ist das beste $\lambda$?

Ganz einfach: wenn wir annehmen, das nichts furchtbar schief gelaufen ist und wir eine sehr unglückliche Stichprobe gezogen haben, dann sollten wir das $\lambda$ wählen, welches die **gemeinsame Wahrscheinlichkeit genau diese Stichprobe** zu ziehen maximiert.

```python
def likelihood_viewer(lam=1.5):
    xaxis = np.linspace(0, lam, num=1000)
    yaxis = poisson_likelihood(xaxis)

    fig, ax = plt.subplots(figsize=(15, 5))
    label = r"Likelihood für $\lambda$={:3.3}".format(lam)
    ax.plot(xaxis, yaxis, c="orange", label="Likelihood", lw=3)
    ax.plot(xaxis[-1], yaxis[-1], "o", c="red", label=label)

    ax.set_xlim([0, 6])
    ax.set_ylim([-1e-49, 4e-48])

    fig.suptitle(r"Likelihood für verschiedene $\lambda$", fontsize=16)
    plt.legend()

lambda_slider = FloatSlider(min=0.01, max=6, value=1.5, step=0.01, description=r"$\lambda$");
```

$$
L(\lambda) = \exp(-n\lambda)\prod\limits_{i=1}^n \frac{\lambda^{x_i}}{x_i !}
$$

```python
interact(likelihood_viewer, lam=lambda_slider);
```

    interactive(children=(FloatSlider(value=1.5, description='$\\lambda$', max=6.0, min=0.01, step=0.01), Output()…

Uns sollten mehrere Dinge auffallen:
* wenn $\lambda$ zu klein ist, nimmt auch die Likelihood-Funktion einen kleinen Wert an.
* wenn $\lambda$ zu groß ist, nimmt die Likelihood-Funktion einen Wert an der ebenfalls klein ist.
* wir suchen also nach einer Goldlöckchen-Lösung.

### Optimieren der Likelihood-Funktion

#### 0. Logarithmieren

Du dachtest wir würden gleich mit dem Ableiten beginnen, was? Die Funktion hier ist noch ok, wir müssten die Produkt und gegebenenfalls die Kettenregel anwenden - aber meine Güte, was wäre das für ein Aufwand!

Stattdessen verwenden wir eine monotone Transformation, die aus Produkten Summen macht. Und weil sie monoton ist, verändert sie die Lage des Maximums nicht. Wir logarithmieren die Likelihood-Funktion und bekommen so die **log-Likelihood-Funktion** $l$:

\begin{align}
l &= \log(L) = \log\left(\exp(-n\lambda) \prod\limits_{i=1}^n \frac{\lambda^{x_i}}{x_i!}\right) \\
  &= \log\left(\exp(-n\lambda)\right) +  \log\left( \prod\limits_{i=1}^n \frac{\lambda^{x_i}}{x_i!} \right) \\
  &= -n\lambda + \sum\limits_{i=1}^n \log\left( \frac{\lambda^{x_i}}{x_i!} \right)
\end{align}

Und die können wir relativ bequem ableiten

#### 1. Ableitung gleich Null setzen

\begin{align}
\frac{\partial l}{\partial \lambda} &= -n + \sum\limits_{i=1}^n \overbrace{\frac{1}{\frac{\lambda^{x_i}}{x_i!}}}^{\text{Äußere Ableitung}} \cdot \underbrace{\frac{x_i}{x_i!} \lambda^{x_i - 1}}_{\text{innere Ableitung}} \\
  &= -n + \sum\limits_{i=1}^n \frac{x_i}{\lambda^{x_i}} \lambda^{x_i - 1} = -n + \sum\limits_{i=1}^n \frac{x_i \color{red}{\lambda^{x_i}}}{\color{red}{\lambda^{x_i}}\cdot\lambda} \\
  &= -n + \frac{1}{\lambda} \sum\limits_{i=1}^n x_i \stackrel{!}{=} 0
\end{align}

Das ist unsere notwendige Bedingung um ein Maximum zu finden.

\begin{align}
0 &\stackrel{!}{=} -n + \frac{1}{\lambda} \sum\limits_{i=1}^n x_i \\
\Leftrightarrow \lambda_{ML}^* &= \frac{1}{n}\sum\limits_{i=1}^n x_i
\end{align}

Und wir sehen, dass der beste Schätzer das arithmetische Mittel war.

```python
def plot_likelihoods():
    fig, ax = plt.subplots(nrows=2, ncols=1, figsize=(15, 10), sharex=True)
    x = np.linspace(2, 4, num=1000)
    y1 = poisson_likelihood(x)
    y2 = np.log(y1)
    xbar = tinder["Anzahl Matches"].values.mean()
    label = r"$\lambda_{ML}^*" + "= {:3.2}$".format(xbar)

    ax[0].plot(x, y1, c="orange", lw=3)
    ax[0].plot([xbar, xbar], [-1, 1])
    ax[0].set_ylim([0, 4e-48])
    ax[0].set_title("Likelihood")
    ax[1].plot(x, y2, c="orange", lw=3)
    ax[1].plot([xbar, xbar], [-175, -100], label=label)
    ax[1].set_title("Log-Likelihood")
    ax[1].set_ylim([-120, -108])
    plt.legend()
    plt.tight_layout()
    return fig, ax
```

```python
plot_likelihoods();
```

## Wartezeit auf das erste Match