TCII Github

Trabajo Semanal 3

Filtro PasaBanda con Chebyshev

Liaño, Lucas

## Consigna

>

# Resolución a Mano

## Implementación Computarizada

#### Importamos las librerias a utilizar

```python
import numpy as np
import matplotlib.pyplot as plt
from splane import pzmap, grpDelay, bodePlot, convert2SOS
from scipy import signal

# Esta es una liberia tomada de la comunidad [https://stackoverflow.com/questions/35304245/multiply-scipy-lti-transfer-functions?newreg=b12c460c179042b09ad75c2fb4297bc9]
from ltisys import *

# Módulos para Jupyter (mejores graficos!)
import warnings
warnings.filterwarnings('ignore')
plt.rcParams['figure.figsize'] = [12, 4]
plt.rcParams['figure.dpi'] = 150 # 200 e.g. is really fine, but slower
```

#### Inicializamos las variables utilizadas

Cargamos los valores correspondientes a la trasnferencia pasabanda normalizada.

### Transferencia $T_{1}(s)$
\begin{equation}
    T_{1}(s) = \dfrac{s\ \dfrac{1}{q_{1}}}{s^2 + s\ \dfrac{1}{q_{1}} + 1}
\end{equation}

### Transferencia $T_{2}(s) = T_{21}(s) \cdot T_{22}(s)$
\begin{equation}
    T_{21}(s) = k \dfrac{s\ \dfrac{\omega_{21}}{q_{21}}}{s^2 + s\ \dfrac{\omega_{21}}{q_{21}} + \omega_{21}^2}
\end{equation}

\begin{equation}
    T_{22}(s) = k \dfrac{s\ \dfrac{\omega_{22}}{q_{22}}}{s^2 + s\ \dfrac{\omega_{22}}{q_{22}} + \omega_{22}^2}
\end{equation}

**Nota:** $q_{21} \approx q_{22}$

```python
# Coeficientes Transferencias
q_1 = 7.89
w_1 = 1

k = 3.701
q_21 = 17.3
w_21 = 1.107

q_22 = 17.3
w_22 = 0.902
```

## Definimos la transferencia a partir de la expresión hallada

```python
# Genero la función transferencia T1 en S
num_t1 = [w_1 / q_1, 0]
den_t1 = [1, w_1 / q_1, w_1**2]
T1 = ltimul(num_t1, den_t1);

# Genero la función transferencia T21 en S
num_t21 = [k * w_21 / q_21, 0]
den_t21 = [1, w_21 / q_21, w_21**2]
T21 = ltimul(num_t21, den_t21);

# Genero la función transferencia T22 en S
num_t22 = [k * w_22 / q_22, 0]
den_t22 = [1, w_22 / q_22, w_22**2]
T22 = ltimul(num_t22, den_t22);

# Defino T2
T2 = T21 * T22
```

## Diagrama de polos y ceros

### Transferencia $T_{1}(s)$

```python
pzmap(T1, 1);
```

### Transferencia $T_{2}(s)$, $T_{21}(s)$ y $T_{22}(s)$

```python
pzmap(T21, 1);
pzmap(T22, 1);
pzmap(T2, 1);
```

### Transferencia $H(S) = H_{1}(S) \cdot H_{2}(S)$

    Nota: Estamos utilizando una nueva librería (ver en los imports) que permite multiplicar funciones de transferencia.

```python
T = T1 * T2
pzmap(T, 1);
```

## Diagrama de Bode

### Transferencia $T_{1}(s)$

```python
fig, ax = bodePlot(T1.to_ss(), 2);
ax[0].set_xlim(1e-1,1e1)
ax[0].set_ylim(-100, 10)
ax[1].set_xlim(1e-1,1e1)
```

### Transferencia $T_{21}(s)$

```python
fig, ax = bodePlot(T21.to_ss(), 2);
ax[0].set_xlim(1e-1,1e1)
ax[0].set_ylim(-100, 10)
ax[1].set_xlim(1e-1,1e1)
```

### Transferencia $T_{22}(s)$

```python
fig, ax = bodePlot(T22.to_ss(), 2);
ax[0].set_xlim(1e-1,1e1)
ax[0].set_ylim(-100, 10)
ax[1].set_xlim(1e-1,1e1)
```

### Transferencia $T_{2}(s)$

```python
fig, ax = bodePlot(T2.to_ss(), 2);
ax[0].set_xlim(1e-1,1e1)
ax[0].set_ylim(-100, 10)
ax[1].set_xlim(1e-1,1e1)
```

### Transferencia $T(s) = T_{1}(s) \cdot T_{2}(s)$

```python
fig, ax = bodePlot(T.to_ss(), 2);
ax[0].set_xlim(1e-1,1e1)
ax[0].set_ylim(-100, 10)
ax[1].set_xlim(1e-1,1e1)
```

# Simulación circuital

## Circuito con pasivos

### Diseño a mano

### Simulación
**Circuito Utilizado**

**Transferencia Obtenida**

---

## Circuito con GIC

### Diseño a mano

### Simulación
**Circuito Utilizado**

**Transferencia Obtenida**


# Chapter 4: Classification
- **Chapter 4 from the book [An Introduction to Statistical Learning](https://www.statlearning.com/).**
- **By Gareth James, Daniela Witten, Trevor Hastie and Rob Tibshirani.**
- **Pages from $168$ to $170$**
- **By [Mosta Ashour](https://www.linkedin.com/in/mosta-ashour/)**

**Exercises:**
- **[1.](#1)**
- **[2.](#2)**
- **[3.](#3)**
- **[4.](#4)**
- **[5.](#5)**
- **[6.](#6)**
- **[7.](#7)**
- **[8.](#8)**
- **[9.](#9)**

#  4.7 Exercises
##  Conceptual

### $1.$ Using a little bit of algebra, prove that $(4.2)$ is equivalent to $(4.3)$. In other words, the logistic function representation and logit representation for the logistic regression model are equivalent.

>- **Answer:**

$$p(x) = \frac{e^{\beta_0+\beta_1 x}}{1 + e^{\beta_0+\beta_1 x}}.  \tag{4.2}$$

$\text{Let:   }$
$$z = e^{\beta_0+\beta_1 x}$$

$\text{So:   }$
$$p(x) = \frac{z}{1 + z}$$

$$\frac{1}{p(x)} = \frac{1+z}{z} = \frac{1}{z} + \frac{z}{z}$$

$$\frac{1}{p(x)} = \frac{1}{z} + 1$$

$$\frac{1}{p(x)} - 1 = \frac{1}{z}$$

$$\frac{1}{p(x)} - \frac{p(x)}{p(x)} = \frac{1}{z}$$

$$\frac{1-p(x)}{p(x)} = \frac{1}{z}$$

$$\frac{p(x)}{1-p(x)} = z$$

$$\frac{p(x)}{1-p(x)} = e^{\beta_0+\beta_1 x} \tag{4.3}$$

- **So $(4.2)$ is equivalent to $(4.3)$**.

### $2.$ It was stated in the text that classifying an observation to the class for which $(4.12)$ is largest is equivalent to classifying an observation to the class for which $(4.13)$ is largest. Prove that this is the case. In other words, under the assumption that the observations in the kth class are drawn from a $N(\mu_k,\sigma^2)$ distribution, the Bayes’ classifier assigns an observation to the class for which the discriminant function is maximized.

>- **Answer:**

$$
p_k(x) = \frac{\pi_k \color{red}{\frac{1}{\sqrt{2 \pi \sigma}}} \exp (- \frac{1}{2 \sigma^2} (x-\mu_k)^2)}
                {\sum^{K}_{l=1} \pi_l \color{red}{\frac{1}{\sqrt{2 \pi \sigma}}} \exp(- \frac{1}{2 \sigma^2} (x-\mu_l)^2)}. \tag{4.12}
$$

$$
p_k(x) = \frac{\pi_k \exp (- \frac{1}{2 \sigma^2} (x-\mu_k)^2)}
                {\sum^{K}_{l=1} \pi_l \exp(- \frac{1}{2 \sigma^2} (x-\mu_l)^2)}
$$

$\log(p_k(x)) = \log(\pi_k \exp (- \frac{1}{2 \sigma^2} (x-\mu_k)^2)) -
\log(\sum^{K}_{l=1} \pi_l \exp(- \frac{1}{2 \sigma^2} (x-\mu_l)^2))$

- **$\log(\sum^{K}_{l=1} \pi_l \exp(- \frac{1}{2 \sigma^2} (x-\mu_l)^2)$ doesn't vary with k.**

$$\begin{align}
&= \log(\pi_k) + (- \frac{1}{2 \sigma^2} (x-\mu_k)^2)\\
\\
&= \log(\pi_k) + (- \frac{1}{2 \sigma^2} (x^2 -2x\mu_k + \mu_k^2))\\
\\
&= \log(\pi_k) - \frac{x^2}{2 \sigma^2} + \frac{\color{red}{2}x\mu_k}{\color{red}{2} \sigma^2} - \frac{\mu_k^2}{2 \sigma^2}\\
\end{align}$$

- $\frac{-x^2}{2 \sigma^2}$ doesn't vary with $k$.

- **So $p_k(x)$ is maximized by maximizing:**
$$
\delta_k(x)= x.\frac{\mu_k}{\sigma^2} - \frac{\mu_k^2}{2 \sigma^2} + \log(\pi_k). \tag{4.13}
$$

### $3.$ This problem relates to the $QDA$ model, in which the observations within each class are drawn from a normal distribution with a class- specific mean vector and a class specific covariance matrix. We consider the simple case where $p = 1$; i.e. there is only one feature.

### Suppose that we have $K$ classes, and that if an observation belongs to the $k$th class then $X$ comes from a one-dimensional normal distribution, $X ∼ N(\mu_k,\sigma_{k}^{2})$. Recall that the density function for the one-dimensional normal distribution is given in $(4.11)$. Prove that in this case, the Bayes’ classifier is not linear. Argue that it is in fact quadratic.

### *Hint: For this problem, you should follow the arguments laid out in Section $4.4.2$, but without making the assumption that $\sigma_{1}^{2} = \dots = \sigma_{K}^{2}$.*

>- **When $\sigma_{1}^{2} != \dots != \sigma_{K}^{2}$ then (4.12) would be as following:**

$$
p_k(x) = \frac{\pi_k \frac{1}{\sqrt{2 \pi \sigma_k}} \exp (- \frac{1}{2 \sigma_{k}^{2}} (x-\mu_k)^2)}
                {\sum^{K}_{l=1} \pi_l \frac{1}{\sqrt{2 \pi \sigma_k}} \exp(- \frac{1}{2 \sigma_{k}^{2}} (x-\mu_l)^2)}
$$

>- **In this case, and from the previous question the term $\frac{-x^2}{2 \sigma_{k}^{2}}$ varies with $k$.**
>- **Therefore we can't ignore the quadratic term $x^2$ anymore.**
>- **So $p_k(x)$ is maximized by maximizing:**

$$
\delta_k(x)= \frac{-x^2}{2 \sigma_{k}^{2}} + x.\frac{\mu_k}{\sigma_{k}^{2}} - \frac{\mu_k^2}{2 \sigma_{k}^{2}} + \log(\pi_k)
$$

### $4.$ When the number of features $p$ is large, there tends to be a deterioration in the performance of $\text{KNN}$ and other local approaches that perform prediction using only observations that are near the test observation for which a prediction must be made. This phenomenon is known as the curse of dimensionality, and it ties into the fact that non-parametric approaches often perform poorly when p is large. We will now investigate this curse.

**$(a)$ Suppose that we have a set of observations, each with measurements on $p = 1$ feature, $X$. We assume that $X$ is uniformly (evenly) distributed on $[0,1]$. Associated with each observation is a response value. Suppose that we wish to predict a test observation's response using only observations that are within $10$% of the range of $X$ closest to that test observation. For instance, in order to predict the response for a test observation with $X = 0.6$, we will use observations in the range $[0.55,0.65]$. On average, what fraction of the available observations will we use to make the prediction?**

>**Answer:**
>- **On average, the fraction would be $10\%$ for $x$ in range $[0.05, 0.95]$**

**$(b)$ Now suppose that we have a set of observations, each with measurements on $p = 2$ features, $X_1$ and $X_2$. We assume that $(X_1,X_2)$ are uniformly distributed on $[0,1]\times[0,1]$. We wish to predict a test observation's response using only observations that are within 10 % of the range of $X_1$ and within 10 % of the range of $X_2$ closest to that test observation. For instance, in order to predict the response for a test observation with $X_1 = 0.6$ and $X_2 = 0.35$, we will use observations in the range $[0.55, 0.65]$ for $X_1$ and in the range $[0.3, 0.4]$ for $X_2$. On average, what fraction of the available observations will we use to make the prediction?**

>**Answer:**
>- **For $p=2$, on average, the fraction would be $0.10^{2} = 10^0 \% =  1\%$**

**$(c)$ Now suppose that we have a set of observations on $p = 100$ features. Again the observations are uniformly distributed on each feature, and again each feature ranges in value from $0$ to $1$. We wish to predict a test observation's response using observations within the $10\%$ of each feature's range that is closest to that test observation. What fraction of the available observations will we use to make the prediction?**

>**Answer:**
>- **For $p=100$, on average, the fraction would be $0.10^{100}= 10^{-98}\%$**

**$(d)$ Using your answers to parts $\text{(a) – (c)}$, argue that a drawback of $\text{KNN}$ when p is large is that there are very few training observations “near” any given test observation.**

>- On average, the fraction would be the percentage of the observations of each feature's range over the number of futures $p$

>- For $r_1 \neq \dots \neq r_n$:
>  - the fraction would be $r_1\times r_2\times \dots \times r_n$

>- For $r_1 = \dots = r_n$:
>  - the fraction would be ${r^p}$

>- **where:**
>  - $r_n$ is the range of prediction for each $X_n$
>  - $p$ is the number of features

- As $p$ increases, the geometrically near decrease exponentially.

**$(e)$ Now suppose that we wish to make a prediction for a test observation by creating a p-dimensional hypercube centered around the test observation that contains, on average, $10\%$ of the training observations. For $p = 1,2$, and $100$, what is the length of each side of the hypercube? Comment on your answer.**

*Note: A hypercube is a generalization of a cube to an arbitrary number of dimensions. When $p = 1$, a hypercube is simply a line segment, when $p = 2$ it is a square, and when $p = 100$ it is a 100-dimensional cube.*

>- **Answer**
>  - For $p-dimensional$ hypercube. the equation of length is:
>  - $l^p = r$
>  - **So:**
>  - $l = r^{1/p}$
>  - $l$ is the length of one side.

- **The below plot shows a simulated example where p = 2, in order for $10\%$ of the training observations to fall within the hypercube.**

```python
def hypercube_length(p, perc):
    print(round(perc**(1/p),4))

print("With p = 1:")
hypercube_length(1, 0.1)

print("\nWith p = 2:")
hypercube_length(2, 0.1)

print("\nWith p = 100:")
hypercube_length(100, 0.1)
```

    With p = 1:
    0.1

    With p = 2:
    0.3162

    With p = 100:
    0.9772

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

np.random.seed(1)
# creating the data
df = pd.DataFrame({'x1':np.random.rand(5000),
                   'x2':np.random.rand(5000)})
```

```python
# choising the first observation
re_p = df.iloc[0]-[0.3162/2, 0.3162/2]

fig, ax = plt.subplots(figsize=(12,12))

# Display the points
ax.scatter('x1', 'x2', data=df, alpha=0.4)
ax.scatter('x1', 'x2', data=df.iloc[0], c='black')

# Create a hypercube patch
rect = plt.Rectangle(re_p, 0.3162, 0.3162, alpha=0.4, linewidth=5, edgecolor='r', facecolor='r');

# Add the patch to the Axes
ax.add_patch(rect);
```

### $5.$ We now examine the differences between $LDA$ and $QDA$.

**$(a)$ If the Bayes decision boundary is linear, do we expect $LDA$ or $QDA$ to perform better on the training set? On the test set?**

>- **Answer:**
>  - On the **Training** set: $QDA$ should perform better because of its higher flexibility to fit noise on the data "overfitting".
>  - On the **Test** set: $LDA$ should perform better as $QDA$ overfitted the training set.

**$(b)$ If the Bayes decision boundary is non-linear, do we expect $LDA$ or $QDA$ to perform better on the training set? On the test set?**

>- **Answer:**
>  - On the **Training** set: $QDA$ should perform way better because of its higher flexibility to fit noise on the data "overfitting".
>  - On the **Test** set: $QDA$ still should perform better.

**$(c)$ In general, as the sample size $n$ increases, do we expect the test prediction accuracy of $QDA$ relative to $LDA$ to improve, decline, or be unchanged? Why?**

>- **Answer:**
>  - The test prediction accuracy would **improve**, as sample size increases, a higher flexibility model will fit the data more.

**$(d)$ True or False: Even if the Bayes decision boundary for a given problem is linear, we will probably achieve a superior test error rate using $QDA$ rather than $LDA$ because $QDA$ is flexible enough to model a linear decision boundary. Justify your answer.**

>- **False**. More flexible model tend to `overfit` the training set plus the noise, that will increase its test error rate.

### $6.$ Suppose we collect data for a group of students in a statistics class with variables $X_1 =$ hours studied, $X_2 =$ undergrad $GPA$, and $Y =$ receive an A. We fit a logistic regression and produce estimated coefficient, $\beta^0 = −6, \beta^1 = 0.05$, $\beta^2 = 1$.

**$(a)$ Estimate the probability that a student who studies for 40 h and has an undergrad $GPA$ of $3.5$ gets an A in the class.**

>- For multiple logistic regression a prediction p(X) is given by

$$p(X) = \frac{\exp{(\beta_0+\beta_1 X_1 + \beta_2 X_2)}}{1 + \exp{(\beta_0+\beta_1 X_1 + \beta_2 X_2)}}$$

```python
beta  = np.array([-6, 0.05, 1])
X     = np.array([1, 50, 3.5])

lr = np.exp(np.dot(beta, X)) / (1 + np.exp(np.dot(beta, X)))

print('p(X) = ' + str(np.around(lr, 4)))
```

    p(X) = 0.5

**$(b)$ How many hours would the student in part $(a)$ need to study to have a $50\%$ chance of getting an A in the class?**

$$p(X) = \frac{\exp{(\beta_0+\beta_1 X_1 + \beta_2 X_2)}}{1 + \exp{(\beta_0+\beta_1 X_1 + \beta_2 X_2)}}$$

$$0.5 = \frac{\exp{(0.05X_1 + -2.5)}}{\color{red}{1 + \exp{(0.05X_1 + -2.5)}}}$$

$$ 0.5 + \color{red}{0.5\exp{(0.05X_1 + -2.5)}} = \exp{(0.05X_1 + -2.5)}$$

$$ 0.5 = 0.5\exp{(0.05X_1 + -2.5)} \tag{*2}$$

$$ 1 = \exp{(0.05X_1 + -2.5)} \tag{log}$$

$$ 1 = 0.05X_1 + -2.5$$

$$ 0.05X_1 = 2.5$$

$$ X_1 = \frac{2.5}{0.05} = 50 \text{ hours}$$

### $7.$ Suppose that we wish to predict whether a given stock will issue a dividend this year ("Yes" or "No") based on $X$, last year's percent profit. We examine a large number of companies and discover that the mean value of $X$ for companies that issued a dividend was $\bar{X}= 10$, while the mean for those that didn't was $\bar{X}= 0$. In addition, the variance of $X$ for these two sets of companies was $\bar{\sigma}^2 = 36$. Finally, $80\%$ of companies issued dividends. Assuming that $X$ follows a normal distribution, predict the probability that a company will issue a dividend this year given that its percentage profit was $X = 4$ last year.

### Hint: Recall that the density function for a normal random variable is $f(x) = \frac{1}{\sqrt{2\pi\sigma^2}} e^{-(x-\mu)^2/2\sigma^2}$ . You will need to use Bayes' theorem.

$$
p_k(x) = \frac {\pi_k
                \frac {1} {\sqrt{2 \pi} \sigma}
                \exp(- \frac {1} {2 \sigma^2} (x - \mu_k)^2)
               }
               {\sum {
                \pi_l
                \frac {1} {\sqrt{2 \pi} \sigma}
                \exp(- \frac {1} {2 \sigma^2} (x - \mu_l)^2)
               }}
\\
       p_{yes}(x)= \frac {\pi_{yes}
                \exp(- \frac {1} {2 \sigma^2}  (x - \mu_{yes})^2)
               }
               {\sum {
                \pi_l
                \exp(- \frac {1} {2 \sigma^2} (x - \mu_l)^2)
               }}
\\
       = \frac {\pi_{yes} \exp(- \frac {1} {2 \sigma^2}  (x - \mu_{yes})^2)}
               {
                \pi_{yes} \exp(- \frac {1} {2 \sigma^2} (x - \mu_{yes})^2) +
                \pi_{no}  \exp(- \frac {1} {2 \sigma^2}  (x - \mu_{no})^2)
               }
\\
      = \frac {0.80 \exp(- \frac {1} {2 * 36}  (x - 10)^2)}
               {
                0.80 \exp(- \frac {1} {2 * 36} (x - 10)^2) +
                0.20 \exp(- \frac {1} {2 * 36}  x^2)
               }
\\
p_{yes}(4)  = \frac {0.80 \exp(- \frac {1} {2 * 36}  (4 - 10)^2)}
               {
                0.80 \exp(- \frac {1} {2 * 36} (4 - 10)^2) +
                0.20 \exp(- \frac {1} {2 * 36}  4^2)
               }
        = 75.19\%
$$

```python
round(0.8*np.exp(-(1/72) *36) / (0.8*np.exp(-(1/72) *36) + (0.2*np.exp(-16/72))), 4) * 100
```

    75.19

### $8.$ Suppose that we take a data set, divide it into equally-sized training and test sets, and then try out two different classification procedures. First we use logistic regression and get an error rate of $20\%$ on the training data and $30\%$ on the test data. Next we use 1-nearest neighbors $(i.e. K = 1)$ and get an average error rate (averaged over both test and training data sets) of $18\%$. Based on these results, which method should we prefer to use for classification of new observations? Why?

>- **Answer:**
>  - For the $\text{KNN}$ approach with $K=1$, the error rate would be $0\%$ on the training data due to its high overfitting every single observation on the training data. furthermore, with average error rate of $18\%$, the test error will be $36\%$.
>  - Comparing our results with the logistic regression approach, we would recommend using **logistic regression** for predicting a new observations with a lower test error rate of $30\%$.

### 9. This problem has to do with odds.
**$(a)$ On average, what fraction of people with an odds of $0.37$ of defaulting on their credit card payment will in fact default?**
>- **Answer:**
>  - **Since the odds equals to** $\frac{p(X)}{1 - p(X)} = 0.37$
>  - **Then...**
>  - $p(X) = 0.37 - 0.37p(X)$
>  - $1.37p(X) = 0.37$
>  - **So...**
>  - $p(X) = 0.37 / 1.37 = 0.27$

**$(b)$ Suppose that an individual has a $16\%$ chance of defaulting on her credit card payment. What are the odds that she will default?**

>- **Answer:**
>  - $odds = \frac{p(X)}{1 - p(X)} = \frac{0.16}{1 - 0.16} = 0.19$

## Done!


```python
import numpy as np
import matplotlib.pyplot as plt
import scipy
from sklearn.model_selection import ParameterGrid
from sklearn.manifold import Isomap
import time
from tqdm import tqdm

import librosa
from librosa import cqt
from librosa.core import amplitude_to_db
from librosa.display import specshow

import os
import glob
```

```python
hop_size= 512
q= 24
```

```python
import h5py
with h5py.File("NTVow.h5", "r") as f:
    features_dict = {key:f[key][()] for key in f.keys()}
```

```python
len(list(features_dict.keys()))
```

    3190

```python
grid = {
    'Q': [24],
    'k': [3],
    'comp': ['log'],
    'instr': ['all'],
    'dyn': ['all']
}

settings = list(ParameterGrid(grid))

for setting in settings:

    if setting["instr"] == 'all':
        setting['instr'] = ''

    if setting['dyn'] == 'all':
        setting['dyn'] = ''
```

```python
batch_str = []
CQT_OCTAVES = 7

features_keys = list(features_dict.keys())

for setting in settings:

    q = setting['Q']
    # Batch process and store in a folder
    batch_str = [setting['instr'], setting['dyn']]

    batch_features = []
    for feature_key in features_keys:
        # Get features that match setting

        if all(x in feature_key for x in batch_str):
            batch_features.append(features_dict[feature_key])

    batch_features = np.stack(batch_features, axis=1)

    # Isomap parameters
    hop_size = 512
    compression = 'log'
    features = amplitude_to_db(batch_features)
    n_neighbors = setting['k']
    n_dimensions = 3
    n_octaves = 3

    # Prune feature matrix
    bin_low = np.where((np.std(features, axis=1) / np.std(features)) > 0.1)[0][0] + q
    bin_high = bin_low + n_octaves*q
    X = features[bin_low:bin_high, :]

    # Z-score Standardization- improves contrast in correlation matrix
    mus = np.mean(X, axis=1)
    sigmas = np.std(X, axis=1)
    X_std = (X - mus[:, np.newaxis]) / (1e-6 + sigmas[:, np.newaxis]) # 1e-6 to avoid runtime division by zero

    # Pearson correlation matrix
    rho_std = np.dot(X_std, X_std.T) / X_std.shape[1]

    # Isomap embedding
    isomap = Isomap(n_components= n_dimensions, n_neighbors= n_neighbors)
    coords = isomap.fit_transform(rho_std)

    # Get note value
    freqs= librosa.cqt_frequencies(q*CQT_OCTAVES, fmin=librosa.note_to_hz('C1'), bins_per_octave=q) #librosa CQT default fmin is C1
    chroma_list= librosa.core.hz_to_note(freqs[bin_low:bin_high])

    notes = []
    reps = q//12
    for chroma in chroma_list:
        for i in range(reps):
            notes.append(chroma)
```

```python
curr_fig= plt.figure(figsize=(5.5, 2.75))
ax= curr_fig.add_subplot(121)
ax.axis('off')

import colorcet as cc
subsampled_color_ids = np.floor(np.linspace(0, 256, q, endpoint=False)).astype('int')
color_list= [cc.cyclic_mygbm_30_95_c78[i] for i in subsampled_color_ids]

# Plot embedding with color
for i in range(coords.shape[0]):
    plt.scatter(coords[i, 0], coords[i, 1], color= color_list[i%q], s=30.0)

plt.plot(coords[:, 0], coords[:, 1], color='black', linewidth=0.2)

# Plot Pearson correlation matrix
rho_frequencies = freqs[bin_low:bin_high]

freq_ticklabels = ['A2', 'A3', 'A4']
freq_ticks = librosa.core.note_to_hz(freq_ticklabels)

tick_bins = []
tick_labels= []
for i,freq_tick in enumerate(freq_ticks):
    tick_bin = np.argmin(np.abs(rho_frequencies-freq_tick))
    tick_bins.append(tick_bin)
    tick_labels.append(freq_ticklabels[i])

plt.figure(figsize=(2.5,2.5))
plt.imshow(np.abs(rho_std), cmap='magma_r')
plt.xticks(tick_bins)
plt.gca().set_xticklabels(freq_ticklabels)
# plt.xlabel('Log-frequency (octaves)')
plt.yticks(tick_bins)
plt.gca().set_yticklabels(freq_ticklabels)
# plt.ylabel('Log-frequency (octaves)')
plt.gca().invert_yaxis()

plt.clim(0, 1)

```

```python
import circle_fit
import importlib
importlib.reload(circle_fit)
from circle_fit import circle_fit

A = np.transpose(coords[:,:-1])
x, r, circle_residual = circle_fit(A, verbose=True)
```

```python
import matplotlib
matplotlib.rc('font', family='serif')

fig, axes = plt.subplots()
plt.scatter(A[0,:],A[1,:])
plt.plot(x[0],x[1],'rx')

circle = plt.Circle(x, radius=r, fill=False, linestyle='-.')

axes.set_aspect(1)
axes.add_artist(circle)

# axes.set_ylim([-5,6])
# axes.set_xlim([-2,8])

plt.title('Circle fit: TinySOL all instr', pad=10.0)
plt.show()

print(np.sqrt(circle_residual)/72)
```

```python
def d_squared(a, b):
    # Takes two n-D tuples and returns euclidean distance between them

    # Cast to array for computation
    # Cast first to tuple in case a or b are Sympy Point objects
    p_a = np.array(tuple(a), dtype='float')
    p_b = np.array(tuple(b), dtype='float')

    return np.sum(np.square(p_a - p_b))
```

```python
import sympy

from sympy.geometry import Circle, Point, Line

center = Point(x, evaluate=False)
c = Circle(center, r, evaluate=False)

l = Line(Point(coords[0,:-1]), center, evaluate=False)
points = [tuple(p) for p in l.points]

xy_prime = []

# TODO: Optimize to a more pythonic manner
for x,y in coords[:,:2]:

    intersections = c.intersection(Line(Point(x,y), center, evaluate=False))

    if d_squared((x,y),intersections[0]) < d_squared((x,y), intersections[1]):
        xy_prime.append([float(p) for p in intersections[0]])
    else:
        xy_prime.append([float(p) for p in intersections[1]])

```

```python
fig, axes = plt.subplots()
plt.scatter(np.array(xy_prime)[:,0],np.array(xy_prime)[:,1], s=10,
            label='projected points')
plt.scatter(A[0,:],A[1,:], s=0.5, label='isomap embedding points (2D)')
plt.plot(center[0],center[1],'rx')

circle = plt.Circle([float(p) for p in center], radius=r, fill=False,
                    linestyle='--', label='estimated circle fit')

axes.set_aspect(1)
axes.add_artist(circle)

plt.title('Projected points on circle', pad=10.0)
plt.legend(bbox_to_anchor=(1,1))
plt.show()

```

```python
z = np.arange(len(coords[:,2]))
z_fit = scipy.stats.linregress(z, coords[:,2])
print(z_fit.stderr)
```

    0.015001619033788483

```python
plt.figure()
plt.title('Line fit: TinySOL all instr')
plt.scatter(np.arange(len(coords[:,2])), coords[:,2])

plt.plot(z_fit.intercept + z_fit.slope*z, 'b')
```

```python
# New line coordinates
z_prime = [i * z_fit.slope + z_fit.intercept for i,_ in enumerate(coords[:,2])]
```

```python
coords_prime = np.append(np.array(xy_prime), np.expand_dims(np.array(z_prime), axis=1), axis=1)
coords_length = coords_prime.shape[0]
```

```python
# Projected helix self-distance matrix

D_proj = np.zeros((coords_length, coords_length))
for i in range(coords_length):
    for j in range(i,coords_length):

        D_proj[i][j] = d_squared(coords_prime[i,:], coords_prime[j,:])

# Isomap embedding self-distance matrix

D_isomap = np.zeros((coords_length, coords_length)) # Projected points same no. as isomap
for i in range(coords_length):
    for j in range(i, coords_length):

        D_isomap[i][j] = d_squared(coords[i,:], coords[j,:])

# Geodesic self-distance matrix

D_geodesic = isomap.dist_matrix_

# Convert to upper triangular sparse matrix
for i in range(coords_length):
    for j in range(i):
        D_geodesic[i,j] = 0
```

```python
## Centering matrix

def centered(A, Q=24, J=3):
    # Returns centered distance matrix

    '''
    Inputs
    -----
    A - squared distance matrix
    Q - quality factor, 24 by default
    J - number of octaves, 3 by default

    Returns
    -----
    tau - MDS style diagonalized matrix of A
    '''

    coords_length = A.shape[0]
    H = np.zeros((coords_length, coords_length))

    const = 1/(Q*J)
    for i in range(coords_length):
        for j in range(coords_length):
            if j==i:
                H[i,j] = 1 - const
            else:
                H[i,j] = -const

    return -0.5 * np.matmul(np.matmul(H, A), H)
```

```python
def frobenius_distance(A, B):
    # Given two nxn matrices, return their 'Frobenius distance'

    return np.sqrt(np.sum(np.square(A - B)))
```

```python
loss_isomap = frobenius_distance(centered(D_geodesic), centered(D_isomap))/coords_length
loss_total = frobenius_distance(centered(D_geodesic), centered(D_proj))/coords_length
loss_proj = frobenius_distance(centered(D_isomap), centered(D_proj))/coords_length

print(f"Isomap loss= {loss_isomap}")
print(f"Projection loss= {loss_proj}")
print(f"Total loss= {loss_total}")
```

    Isomap loss= 16.262512975570516
    Projection loss= 8.432097710558947
    Total loss= 15.011785144620099

```python
(loss_total) - (loss_isomap + loss_proj) < 0
```

    True


# Practical 2A: Recurrent Neural Networks (RNNs)

© Deep Learning Indaba. Apache License 2.0.

Adapted for Khipu by Diego de Las Casas and José Lezama

## Introduction

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

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

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

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

## Imports

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

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

from __future__ import print_function

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

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

# We need to pin this colab to python2 for now to avoid
# issues with string handling.
import six
assert six.PY2, "This colab needs to be run in a Python2 kernel."
```

##From Feedforward to Recurrent Models

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

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

```python
def ffn_forward(x, W_xh, W_ho, b_hid, b_out):
    # Compute activations on the hidden layer.
    hidden_layer = np.tanh(np.dot(W_xh, x) + b_hid)
    # Compute the (linear) output layer activations.
    output = np.dot(W_ho, hidden_layer) + b_out
    return output
```

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

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

```python
def rnn_forward(data_sequence, initial_state):
    state = initial_state  # the variable state is updated at every time-step
    all_ys = [] # Used to save all predictions
    for x in data_sequence:
      # rnn_cell is a function (not shown) that it takes the current input x_t
      # and the previous state h, and produces an output y_t and a new state h.
      y, new_state = rnn_cell(x, state)
      all_ys.append(y)
      # Update state for the next time-step
      state = new_state
    return all_ys
```

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

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

### Unrolling the network

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

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

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

### Putting this together

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

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

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

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

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

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

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

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

##Modeling General Time-Series

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

###Create some artificial data

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```

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

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

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

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

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

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

* **Memory usage grows linearly with the total number of steps $T$ that we unroll for**, because we need to save/cache the activations at each time-step (this happens implicitly in Tensorflow, when the activations are stored in the [gradient tape](https://www.tensorflow.org/api_docs/python/tf/GradientTape)).  This matters computationally, since memory is a limited resource. It also matters statistically, because it puts a limit on the types of dependency that the model can successfully learn, by preventing it from correcting errors that stem from an input more than $T$ steps ago.

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

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

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

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

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

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

The problem is that $\frac{\partial h_t}{\partial h_k} = \Pi_j \frac{\partial h_j}{\partial h_{j-1}}$ for j from $k + 1$ to $t$.  Because of this **repeated multiplicative interaction**, as the sequence length $t$ gets longer, the gradients themselves can get diminishingly small (**vanish**) or grow too large and result in numeric overflow (**explode**). Ideally, in order to avoid these beheviors, the norms of the gradients should be close to 1.

###Build a tiny RNN in Keras

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

```
def define_model(truncated_seq_len):

    input_dimension = 1
    hidden_dimension = 1
    output_dimension = 1

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

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

    return model

```

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

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

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

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

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

Let's visualize the training and validation losses.

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

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

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

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

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

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

###Make predictions using the trained model

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

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

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

##Generating Text

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

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

### Download and Preprocess the Data

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

```
context = ssl._create_unverified_context()

LANGUAGE = 'sp' #@param ['en', 'sp']

corpora = dict(
    sp=('http://www.gutenberg.org/cache/epub/2000/pg2000.txt', 28142),
    en=('https://cs.stanford.edu/people/karpathy/char-rnn/shakespeare_input.txt', 0)
)

corpus_url, text_start = corpora[LANGUAGE]

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

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

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

We now preprocess the text data as follows:

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

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

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

```
max_len = 30  # We only consider this many previous data points (characters)
step = 3 # We start a new training sequence every `step` characters

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

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

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

```
sentences = [] # This holds our extracted sequences
next_chars = [] # This holds the targets (the follow-up characters)
for i in range(0, len(training_text) - max_len, step):
    sentences.append([char2index[s] for s in training_text[i: i + max_len]])
    next_chars.append([char2index[training_text[i + max_len]]])

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

This yields the following numpy arrays:

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

Let's take a look at the first example.

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

###Build an RNN language model

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

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

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

There are many different ways in which to estimate these individual probabilities. But one particularly effective way is to use an RNN!

To do this, we'll therefore be modeling the  $p(x_i | \mathbb{x}_{1:i-1})$ terms using an RNN conditioned on $\mathbb{x}_{1:i-1}$.

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

We can do this with a few lines of code:

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

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

model.summary()
```

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

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

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

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

Our language model above is using an RNN variant called the LSTM (long short-term memory). The LSTM addresses a big problem with the vanilla RNN: vanishing and exploding gradients. This makes them very effective in encoding long-term dependencies. The key to the success of the LSTM is that it separates it's **hidden state** (i.e. the output that will go to the next layer) from an internal **cell state** ($\mathbf{c}_t$) which can be thought of as a 'memory' for the LSTM. The cell state provides a path along which the gradients can easily flow.

The LSTM can control the contents of the cell state using three **gates**. Gates are defined as sigmoid functions of the input and hidden states, all with their own weights and biases (much like a sigmoid linear layer):

* The **forget** gate allows the LSTM to remove contents from the cell state.
* The LSTM computes an update to the cell state using a `tanh` transformation from the inputs and hidden state. This is then modulated by the **input** gate.
* Finally, an **output** gate controls which part of the cell state will be output as hidden state at each time step.

The gates allow the LSTM to "shut down" the flow of information, which allows the network to only propagate useful information on its gradients. This makes it more capable of encoding long-term dependencies without risking them becoming unstable.

A somewhat simplified version of the LSTM is the GRU (gated recurrent unit), which has also been used successfully in many applications. The GRU has only two gates, and does not have a separate cell state.

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

###Helper functions

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

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

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

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

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

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

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

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

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

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

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

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

            next_index = sample_with_temp(preds, temp)
            next_char = index2char[next_index]
            #print(next_index, end=' -> ')
            generated_text += next_char
            generated_text = generated_text[1:]

            # Left-shift and add into encoded array
            generated_text_arr = shift_and_append(generated_text_arr, next_index)
            print(next_char, end='')
        print()
```

### Try the model with random weights
Let's sample for our model before training it, so we have an idea of the text it generates with the randomly initalized weights (spoiler: you should see absolute nonsense!).

```
sample_from_model(model)
```

###Train the model

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

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

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

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

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

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

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

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

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

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

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

Want to see what the model yields after much more training? Here we provide pre-trained models for these tasks:

```
!pip install -q googledrivedownloader
from google_drive_downloader import GoogleDriveDownloader as gdd

params = dict(
    en=('1OTpvsXVQ_k754hlVgZB5yk-K8wuy0mMK', 'shakespeare_final.h5'),
    sp=('1tU_IFP_6L9VfjdqGX3Q9Y6aQTB01n_om', 'quijote_final.h5')
)
file_id, filename = params[LANGUAGE]
gdd.download_file_from_google_drive(
    file_id=file_id, dest_path='./{}'.format(filename))
model.load_weights(filename)
```

```
sample_from_model(model)
```

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

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

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

##Further reading

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


# Getting started with MIPLearn

## Introduction

**MIPLearn** is an open source framework that uses machine learning (ML) to accelerate the performance of both commercial and open source mixed-integer programming solvers (e.g. Gurobi, CPLEX, XPRESS, Cbc or SCIP). In this tutorial, we will:

1. Install the Julia/JuMP version of MIPLearn
2. Model a simple optimization problem using JuMP
3. Generate training data and train the ML models
4. Use the ML models together with SCIP to solve new instances

Note

In this tutorial, we use SCIP because it is more widely available than commercial MIP solvers. However, all the steps below should work for Gurobi, CPLEX or XPRESS, as long as you have a license for these solvers. The performance impact of MIPLearn may also change for different solvers.

Warning

MIPLearn is still in early development stage. If run into any bugs or issues, please submit a bug report in our GitHub repository. Comments, suggestions and pull requests are also very welcome!

## Installation

MIPLearn is available in two versions:

- Python version, compatible with the Pyomo modeling language,
- Julia version, compatible with the JuMP modeling language.

In this tutorial, we will demonstrate how to use and install the Julia/JuMP version of the package. The first step is to install the Julia programming language in your computer. [See the official instructions for more details](https://julialang.org/downloads/). Note that MIPLearn was developed and tested with Julia 1.6, and may not be compatible with newer versions of the language. After Julia is installed, launch its console and run the following commands to download and install the package:

```julia
using Pkg
Pkg.add(PackageSpec(url="https://github.com/ANL-CEEESA/MIPLearn.jl.git"))
```

        Updating git-repo `https://github.com/ANL-CEEESA/MIPLearn.jl.git`
        Updating registry at `~/.julia/registries/General`
        Updating git-repo `https://github.com/JuliaRegistries/General.git`
       Resolving package versions...
      No Changes to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Project.toml`
      No Changes to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Manifest.toml`

In addition to MIPLearn itself, we will also install a few other packages that are required for this tutorial:

- [**SCIP**](https://www.scipopt.org/), one of the fastest non-commercial MIP solvers currently available
- [**JuMP**](https://jump.dev/), an open source modeling language for Julia
- [**Distributions.jl**](https://github.com/JuliaStats/Distributions.jl), a statistics package that we will use to generate random inputs
- [**Glob.jl**](https://github.com/vtjnash/Glob.jl), a package that retrieves all files in a directory matching a certain pattern

```julia
using Pkg
Pkg.add([
    PackageSpec(url="https://github.com/scipopt/SCIP.jl.git", rev="7aa79aaa"),
    PackageSpec(name="JuMP", version="0.21"),
    PackageSpec(name="Distributions", version="0.25"),
    PackageSpec(name="Glob", version="1"),
])
```

       Resolving package versions...
      No Changes to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Project.toml`
      No Changes to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Manifest.toml`

Note

In the code above, we install specific version of all packages to ensure that this tutorial keeps running in the future, even when newer (and possibly incompatible) versions of the packages are released. This is usually a recommended practice for all Julia projects.

## Modeling a simple optimization problem

To illustrate how can MIPLearn be used, we will model and solve a small optimization problem related to power systems optimization. The problem we discuss below is a simplification of the **unit commitment problem,** a practical optimization problem solved daily by electric grid operators around the world.

Suppose that you work at a utility company, and that it is your job to decide which electrical generators should be online at a certain hour of the day, as well as how much power should each generator produce. More specifically, assume that your company owns $n$ generators, denoted by $g_1, \ldots, g_n$. Each generator can either be online or offline. An online generator $g_i$ can produce between $p^\text{min}_i$ to $p^\text{max}_i$ megawatts of power, and it costs your company $c^\text{fix}_i + c^\text{var}_i y_i$, where $y_i$ is the amount of power produced. An offline generator produces nothing and costs nothing. You also know that the total amount of power to be produced needs to be exactly equal to the total demand $d$ (in megawatts). To minimize the costs to your company, which generators should be online, and how much power should they produce?

This simple problem can be modeled as a *mixed-integer linear optimization* problem as follows. For each generator $g_i$, let $x_i \in \{0,1\}$ be a decision variable indicating whether $g_i$ is online, and let $y_i \geq 0$ be a decision variable indicating how much power does $g_i$ produce. The problem is then given by:

$$
\begin{align}
\text{minimize } \quad & \sum_{i=1}^n \left( c^\text{fix}_i x_i + c^\text{var}_i y_i \right) \\
\text{subject to } \quad & y_i \leq p^\text{max}_i x_i & i=1,\ldots,n \\
& y_i \geq p^\text{min}_i x_i & i=1,\ldots,n \\
& \sum_{i=1}^n y_i = d \\
& x_i \in \{0,1\} & i=1,\ldots,n \\
& y_i \geq 0 & i=1,\ldots,n
\end{align}
$$

Note

We use a simplified version of the unit commitment problem in this tutorial just to make it easier to follow. MIPLearn can also handle realistic, large-scale versions of this problem. See benchmarks for more details.

Next, let us convert this abstract mathematical formulation into a concrete optimization model, using Julia and JuMP. We start by defining a data structure that holds all the input data.

```julia
Base.@kwdef struct UnitCommitmentData
    demand::Float64
    pmin::Vector{Float64}
    pmax::Vector{Float64}
    cfix::Vector{Float64}
    cvar::Vector{Float64}
end;
```

Next, we create a function that converts this data structure into a concrete JuMP model. For more details on the JuMP syntax, see [the official JuMP documentation](https://jump.dev/JuMP.jl/stable/).

```julia
using JuMP

function build_uc_model(data::UnitCommitmentData)::Model
    model = Model()
    n = length(data.pmin)
    @variable(model, x[1:n], Bin)
    @variable(model, y[1:n] >= 0)
    @objective(
        model,
        Min,
        sum(
            data.cfix[i] * x[i] +
            data.cvar[i] * y[i]
            for i in 1:n
        )
    )
    @constraint(model, eq_max_power[i in 1:n], y[i] <= data.pmax[i] * x[i])
    @constraint(model, eq_min_power[i in 1:n], y[i] >= data.pmin[i] * x[i])
    @constraint(model, eq_demand, sum(y[i] for i in 1:n) == data.demand)
    return model
end;
```

At this point, we can already use JuMP and any mixed-integer linear programming solver to find optimal solutions to any instance of this problem. To illustrate this, let us solve a small instance with three generators, using SCIP:

```julia
using SCIP

model = build_uc_model(
    UnitCommitmentData(
        demand = 100.0,
        pmin = [10, 20, 30],
        pmax = [50, 60, 70],
        cfix = [700, 600, 500],
        cvar = [1.5, 2.0, 2.5],
    )
)

scip = optimizer_with_attributes(SCIP.Optimizer, "limits/gap" => 1e-4)
set_optimizer(model, scip)
set_silent(model)
optimize!(model)

println("obj = ", objective_value(model))
println("  x = ", round.(value.(model[:x])))
println("  y = ", round.(value.(model[:y]), digits=2));
```

    obj = 1320.0
      x = [0.0, 1.0, 1.0]
      y = [0.0, 60.0, 40.0]

Running the code above, we found that the optimal solution for our small problem instance costs \$1320. It is achieve by keeping generators 2 and 3 online and producing, respectively, 60 MW and 40 MW of power.

## Generating training data

Although SCIP could solve the small example above in a fraction of a second, it gets slower for larger and more complex versions of the problem. If this is a problem that needs to be solved frequently, as it is often the case in practice, it could make sense to spend some time upfront generating a **trained** version of SCIP, which can solve new instances (similar to the ones it was trained on) faster.

In the following, we will use MIPLearn to train machine learning models that can be used to accelerate SCIP's performance on a particular set of instances. More specifically, MIPLearn will train a model that is able to predict the optimal solution for instances that follow a given probability distribution, then it will provide this predicted solution to SCIP as a warm start.

Before we can train the model, we need to collect training data by solving a large number of instances. In real-world situations, we may construct these training instances based on historical data. In this tutorial, we will construct them using a random instance generator:

```julia
using Distributions
using Random

function random_uc_data(; samples::Int, n::Int, seed=42)
    Random.seed!(seed)
    pmin = rand(Uniform(100, 500.0), n)
    pmax = pmin .* rand(Uniform(2.0, 2.5), n)
    cfix = pmin .* rand(Uniform(100.0, 125.0), n)
    cvar = rand(Uniform(1.25, 1.5), n)
    return [
        UnitCommitmentData(;
            pmin,
            pmax,
            cfix,
            cvar,
            demand = sum(pmax) * rand(Uniform(0.5, 0.75)),
        )
        for i in 1:samples
    ]
end;
```

In this example, for simplicity, only the demands change from one instance to the next. We could also have made the prices and the production limits random. The more randomization we have in the training data, however, the more challenging it is for the machine learning models to learn solution patterns.

Now we generate 100 instances of this problem, each one with 1,000 generators. We will use the first 90 instances for training, and the remaining 10 instances to evaluate SCIP's performance.

```julia
data = random_uc_data(samples=100, n=1000);
train_data = data[1:90]
test_data = data[91:100];
```

Next, we write these data structures to individual files. MIPLearn uses files during the training process because, for large-scale optimization problems, it is often impractical to hold the entire training data, as well as the concrete JuMP models, in memory. Files also make it much easier to solve multiple instances simultaneously, potentially even on multiple machines. We will cover parallel and distributed computing in a future tutorial.

The code below generates the files `uc/train/000001.jld2`, `uc/train/000002.jld2`, etc., which contain the input data in [JLD2 format](https://github.com/JuliaIO/JLD2.jl).

```julia
using MIPLearn
MIPLearn.save(data[1:90], "uc/train/")
MIPLearn.save(data[91:100], "uc/test/")

using Glob
train_files = glob("uc/train/*.jld2")
test_files = glob("uc/test/*.jld2");
```

Finally, we use `MIPLearn.LearningSolver` and `MIPLearn.solve!` to solve all the training instances. `LearningSolver` is the main component provided by MIPLearn, which integrates MIP solvers and ML. The `solve!` function can be used to solve either one or multiple instances, and requires: (i) the list of files containing the training data; and (ii) the function that converts the data structure into a concrete JuMP model:

```julia
using Glob
solver = LearningSolver(scip)
@time solve!(solver, train_files, build_uc_model);
```

    103.808547 seconds (93.52 M allocations: 3.604 GiB, 1.19% gc time, 0.52% compilation time)

    WARNING: Dual bound 1.98665e+07 is larger than the objective of the primal solution 1.98665e+07. The solution might not be optimal.

The macro `@time` shows us how long did the code take to run. We can see that SCIP was able to solve all training instances in about 2 minutes. The solutions, and other useful training data, are stored by MIPLearn in `.h5` files, stored side-by-side with the original `.jld2` files.

## Solving new instances

With training data in hand, we can now fit the ML models using `MIPLearn.fit!`, then solve the test instances with `MIPLearn.solve!`, as shown below:

```julia
solver_ml = LearningSolver(scip)
fit!(solver_ml, train_files, build_uc_model)
@time solve!(solver_ml, test_files, build_uc_model);
```

      5.951264 seconds (9.33 M allocations: 334.657 MiB, 1.51% gc time)

The trained MIP solver was able to solve all test instances in about 6 seconds. To see that ML is being helpful here, let us repeat the code above, but remove the `fit!` line:

```julia
solver_baseline = LearningSolver(scip)
@time solve!(solver_baseline, test_files, build_uc_model);
```

     10.390325 seconds (8.17 M allocations: 278.042 MiB, 0.89% gc time)

Without the help of the ML models, SCIP took around 10 seconds to solve the same test instances.

Note

Note that is is not necessary to specify what ML models to use. MIPLearn, by default, will try a number of classical ML models and will choose the one that performs the best, based on k-fold cross validation. MIPLearn is also able to automatically collect features based on the MIP formulation of the problem and the solution to the LP relaxation, among other things, so it does not require handcrafted features. If you do want to customize the models and features, however, that is also possible, as we will see in a later tutorial.

## Understanding the acceleration

Let us go a bit deeper and try to understand how exactly did MIPLearn accelerate SCIP's performance. First, we are going to solve one of the test instances again, using the trained solver, but this time using the `tee=true` parameter, so that we can see SCIP's log:

```julia
solve!(solver_ml, test_files[1], build_uc_model, tee=true);
```

    presolving:
    (round 1, fast)       861 del vars, 861 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    (round 2, fast)       861 del vars, 1722 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    (round 3, fast)       862 del vars, 1722 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    presolving (4 rounds: 4 fast, 1 medium, 1 exhaustive):
     862 deleted vars, 1722 deleted constraints, 0 added constraints, 2000 tightened bounds, 0 added holes, 0 changed sides, 0 changed coefficients
     0 implications, 0 cliques
    presolved problem has 1138 variables (0 bin, 0 int, 0 impl, 1138 cont) and 279 constraints
        279 constraints of type <linear>
    Presolving Time: 0.03

     time | node  | left  |LP iter|LP it/n|mem/heur|mdpt |vars |cons |rows |cuts |sepa|confs|strbr|  dualbound   | primalbound  |  gap   | compl.
    * 0.0s|     1 |     0 |   203 |     - |    LP  |   0 |1138 | 279 | 279 |   0 |  0 |   0 |   0 | 1.705035e+07 | 1.705035e+07 |   0.00%| unknown
      0.0s|     1 |     0 |   203 |     - |  8950k |   0 |1138 | 279 | 279 |   0 |  0 |   0 |   0 | 1.705035e+07 | 1.705035e+07 |   0.00%| unknown

    SCIP Status        : problem is solved [optimal solution found]
    Solving Time (sec) : 0.04
    Solving Nodes      : 1
    Primal Bound       : +1.70503465600131e+07 (1 solutions)
    Dual Bound         : +1.70503465600131e+07
    Gap                : 0.00 %

    violation: integrality condition of variable <> = 0.338047247943162
    all 1 solutions given by solution candidate storage are infeasible

    feasible solution found by completesol heuristic after 0.1 seconds, objective value 1.705169e+07
    presolving:
    (round 1, fast)       0 del vars, 0 del conss, 0 add conss, 3000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    (round 2, exhaustive) 0 del vars, 0 del conss, 0 add conss, 3000 chg bounds, 0 chg sides, 0 chg coeffs, 1000 upgd conss, 0 impls, 0 clqs
    (round 3, exhaustive) 0 del vars, 0 del conss, 0 add conss, 3000 chg bounds, 0 chg sides, 0 chg coeffs, 2000 upgd conss, 1000 impls, 0 clqs
       (0.1s) probing: 51/1000 (5.1%) - 0 fixings, 0 aggregations, 0 implications, 0 bound changes
       (0.1s) probing aborted: 50/50 successive totally useless probings
       (0.1s) symmetry computation started: requiring (bin +, int -, cont +), (fixed: bin -, int +, cont -)
       (0.1s) no symmetry present
    presolving (4 rounds: 4 fast, 3 medium, 3 exhaustive):
     0 deleted vars, 0 deleted constraints, 0 added constraints, 3000 tightened bounds, 0 added holes, 0 changed sides, 0 changed coefficients
     2000 implications, 0 cliques
    presolved problem has 2000 variables (1000 bin, 0 int, 0 impl, 1000 cont) and 2001 constraints
       2000 constraints of type <varbound>
          1 constraints of type <linear>
    Presolving Time: 0.11
    transformed 1/1 original solutions to the transformed problem space

     time | node  | left  |LP iter|LP it/n|mem/heur|mdpt |vars |cons |rows |cuts |sepa|confs|strbr|  dualbound   | primalbound  |  gap   | compl.
      0.2s|     1 |     0 |  1201 |     - |    20M |   0 |2000 |2001 |2001 |   0 |  0 |   0 |   0 | 1.705035e+07 | 1.705169e+07 |   0.01%| unknown

    SCIP Status        : solving was interrupted [gap limit reached]
    Solving Time (sec) : 0.21
    Solving Nodes      : 1
    Primal Bound       : +1.70516871251443e+07 (1 solutions)
    Dual Bound         : +1.70503465600130e+07
    Gap                : 0.01 %

The log above is quite complicated if you have never seen it before, but the important line is the one starting with `feasible solution found [...] objective value 1.705169e+07`. This line indicates that MIPLearn was able to construct a warm start with value `1.705169e+07`. Using this warm start, SCIP then used the branch-and-cut method to either prove its optimality or to find an even better solution. Very quickly, however, SCIP proved that the solution produced by MIPLearn was indeed optimal. It was able to do this without generating a single cutting plane or running any other heuristics; it could tell the optimality by the root LP relaxation alone, which was very fast.

Let us now repeat the process, but using the untrained solver this time:

```julia
solve!(solver_baseline, test_files[1], build_uc_model, tee=true);
```

    presolving:
    (round 1, fast)       861 del vars, 861 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    (round 2, fast)       861 del vars, 1722 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    (round 3, fast)       862 del vars, 1722 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    presolving (4 rounds: 4 fast, 1 medium, 1 exhaustive):
     862 deleted vars, 1722 deleted constraints, 0 added constraints, 2000 tightened bounds, 0 added holes, 0 changed sides, 0 changed coefficients
     0 implications, 0 cliques
    presolved problem has 1138 variables (0 bin, 0 int, 0 impl, 1138 cont) and 279 constraints
        279 constraints of type <linear>
    Presolving Time: 0.03

     time | node  | left  |LP iter|LP it/n|mem/heur|mdpt |vars |cons |rows |cuts |sepa|confs|strbr|  dualbound   | primalbound  |  gap   | compl.
    * 0.0s|     1 |     0 |   203 |     - |    LP  |   0 |1138 | 279 | 279 |   0 |  0 |   0 |   0 | 1.705035e+07 | 1.705035e+07 |   0.00%| unknown
      0.0s|     1 |     0 |   203 |     - |  8950k |   0 |1138 | 279 | 279 |   0 |  0 |   0 |   0 | 1.705035e+07 | 1.705035e+07 |   0.00%| unknown

    SCIP Status        : problem is solved [optimal solution found]
    Solving Time (sec) : 0.04
    Solving Nodes      : 1
    Primal Bound       : +1.70503465600131e+07 (1 solutions)
    Dual Bound         : +1.70503465600131e+07
    Gap                : 0.00 %

    violation: integrality condition of variable <> = 0.338047247943162
    all 1 solutions given by solution candidate storage are infeasible

    presolving:
    (round 1, fast)       0 del vars, 0 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 0 upgd conss, 0 impls, 0 clqs
    (round 2, exhaustive) 0 del vars, 0 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 1000 upgd conss, 0 impls, 0 clqs
    (round 3, exhaustive) 0 del vars, 0 del conss, 0 add conss, 2000 chg bounds, 0 chg sides, 0 chg coeffs, 2000 upgd conss, 1000 impls, 0 clqs
       (0.0s) probing: 51/1000 (5.1%) - 0 fixings, 0 aggregations, 0 implications, 0 bound changes
       (0.0s) probing aborted: 50/50 successive totally useless probings
       (0.0s) symmetry computation started: requiring (bin +, int -, cont +), (fixed: bin -, int +, cont -)
       (0.0s) no symmetry present
    presolving (4 rounds: 4 fast, 3 medium, 3 exhaustive):
     0 deleted vars, 0 deleted constraints, 0 added constraints, 2000 tightened bounds, 0 added holes, 0 changed sides, 0 changed coefficients
     2000 implications, 0 cliques
    presolved problem has 2000 variables (1000 bin, 0 int, 0 impl, 1000 cont) and 2001 constraints
       2000 constraints of type <varbound>
          1 constraints of type <linear>
    Presolving Time: 0.03

     time | node  | left  |LP iter|LP it/n|mem/heur|mdpt |vars |cons |rows |cuts |sepa|confs|strbr|  dualbound   | primalbound  |  gap   | compl.
    p 0.0s|     1 |     0 |     1 |     - |   locks|   0 |2000 |2001 |2001 |   0 |  0 |   0 |   0 | 0.000000e+00 | 2.335200e+07 |    Inf | unknown
    p 0.0s|     1 |     0 |     2 |     - | vbounds|   0 |2000 |2001 |2001 |   0 |  0 |   0 |   0 | 0.000000e+00 | 1.839873e+07 |    Inf | unknown
      0.1s|     1 |     0 |  1204 |     - |    20M |   0 |2000 |2001 |2001 |   0 |  0 |   0 |   0 | 1.705035e+07 | 1.839873e+07 |   7.91%| unknown
      0.1s|     1 |     0 |  1207 |     - |    22M |   0 |2000 |2001 |2002 |   1 |  1 |   0 |   0 | 1.705036e+07 | 1.839873e+07 |   7.91%| unknown
    r 0.1s|     1 |     0 |  1207 |     - |shifting|   0 |2000 |2001 |2002 |   1 |  1 |   0 |   0 | 1.705036e+07 | 1.711399e+07 |   0.37%| unknown
      0.1s|     1 |     0 |  1209 |     - |    22M |   0 |2000 |2001 |2003 |   2 |  2 |   0 |   0 | 1.705037e+07 | 1.711399e+07 |   0.37%| unknown
    r 0.1s|     1 |     0 |  1209 |     - |shifting|   0 |2000 |2001 |2003 |   2 |  2 |   0 |   0 | 1.705037e+07 | 1.706492e+07 |   0.09%| unknown
      0.1s|     1 |     0 |  1210 |     - |    22M |   0 |2000 |2001 |2004 |   3 |  3 |   0 |   0 | 1.705037e+07 | 1.706492e+07 |   0.09%| unknown
      0.1s|     1 |     0 |  1211 |     - |    23M |   0 |2000 |2001 |2005 |   4 |  4 |   0 |   0 | 1.705037e+07 | 1.706492e+07 |   0.09%| unknown
      0.1s|     1 |     0 |  1212 |     - |    23M |   0 |2000 |2001 |2006 |   5 |  5 |   0 |   0 | 1.705037e+07 | 1.706492e+07 |   0.09%| unknown
    r 0.1s|     1 |     0 |  1212 |     - |shifting|   0 |2000 |2001 |2006 |   5 |  5 |   0 |   0 | 1.705037e+07 | 1.706228e+07 |   0.07%| unknown
      0.1s|     1 |     0 |  1214 |     - |    24M |   0 |2000 |2001 |2007 |   6 |  7 |   0 |   0 | 1.705037e+07 | 1.706228e+07 |   0.07%| unknown
      0.2s|     1 |     0 |  1216 |     - |    24M |   0 |2000 |2001 |2009 |   8 |  8 |   0 |   0 | 1.705037e+07 | 1.706228e+07 |   0.07%| unknown
      0.2s|     1 |     0 |  1220 |     - |    25M |   0 |2000 |2001 |2011 |  10 |  9 |   0 |   0 | 1.705037e+07 | 1.706228e+07 |   0.07%| unknown
      0.2s|     1 |     0 |  1223 |     - |    25M |   0 |2000 |2001 |2014 |  13 | 10 |   0 |   0 | 1.705037e+07 | 1.706228e+07 |   0.07%| unknown
     time | node  | left  |LP iter|LP it/n|mem/heur|mdpt |vars |cons |rows |cuts |sepa|confs|strbr|  dualbound   | primalbound  |  gap   | compl.
      0.2s|     1 |     0 |  1229 |     - |    26M |   0 |2000 |2001 |2015 |  14 | 11 |   0 |   0 | 1.705038e+07 | 1.706228e+07 |   0.07%| unknown
    r 0.2s|     1 |     0 |  1403 |     - |intshift|   0 |2000 |2001 |2015 |  14 | 11 |   0 |   0 | 1.705038e+07 | 1.705687e+07 |   0.04%| unknown
    L 0.6s|     1 |     0 |  1707 |     - |    rens|   0 |2000 |2001 |2015 |  14 | 11 |   0 |   0 | 1.705038e+07 | 1.705332e+07 |   0.02%| unknown
    L 0.7s|     1 |     0 |  1707 |     - |    alns|   0 |2000 |2001 |2015 |  14 | 11 |   0 |   0 | 1.705038e+07 | 1.705178e+07 |   0.01%| unknown

    SCIP Status        : solving was interrupted [gap limit reached]
    Solving Time (sec) : 0.68
    Solving Nodes      : 1
    Primal Bound       : +1.70517823853380e+07 (13 solutions)
    Dual Bound         : +1.70503798271962e+07
    Gap                : 0.01 %

In this log file, notice how the previous line about warm starts is missing. Since no warm starts were provided, SCIP had to find an initial solution using its own internal heuristics, which are not specifically tailored for this problem. The initial solution found by SCIP's heuristics has value `2.335200e+07`, which is significantly worse than the one constructed by MIPLearn. SCIP then proceeded to improve this solution, by generating cutting planes and repeatedly running additional primal heuristics. In the end, it was able to find the optimal solution, as expected, but it took longer.

In summary, MIPLearn accelerated the solution process by constructing a high-quality initial solution. In the following tutorials, we will see other strategies that MIPLearn can use to accelerate MIP performance, besides warm starts.

## Accessing the solution

In the example above, we used `MIPLearn.solve!` together with data files to solve both the training and the test instances. The solutions were saved to a `.h5` files in the train/test folders, and could be retrieved by reading theses files, but that is not very convenient. In this section we will use an easier method.

We can use the function `MIPLearn.load!` to obtain a regular JuMP model:

```julia
model = MIPLearn.load("uc/test/000001.jld2", build_uc_model)
```

    A JuMP Model
    Minimization problem with:
    Variables: 2000
    Objective function type: AffExpr
    `AffExpr`-in-`MathOptInterface.EqualTo{Float64}`: 1 constraint
    `AffExpr`-in-`MathOptInterface.GreaterThan{Float64}`: 1000 constraints
    `AffExpr`-in-`MathOptInterface.LessThan{Float64}`: 1000 constraints
    `VariableRef`-in-`MathOptInterface.GreaterThan{Float64}`: 1000 constraints
    `VariableRef`-in-`MathOptInterface.ZeroOne`: 1000 constraints
    Model mode: AUTOMATIC
    CachingOptimizer state: NO_OPTIMIZER
    Solver name: No optimizer attached.
    Names registered in the model: eq_demand, eq_max_power, eq_min_power, x, y

We can then solve this model as before, with `MIPLearn.solve!`:

```julia
solve!(solver_ml, model)
println("obj = ", objective_value(model))
println("  x = ", round.(value.(model[:x][1:10])))
println("  y = ", round.(value.(model[:y][1:10]), digits=2))
```

    obj = 1.7051217395548128e7
      x = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]
      y = [767.11, 646.61, 230.28, 365.46, 1150.99, 1103.36, 0.0, 0.0, 0.0, 0.0]