# Scenario A - Noise Level Variation

This file is used to evaluate the inference (numerical) results.

The model used in the inference of the parameters is formulated as follows:

\begin{equation}
\large y = f(x) = \sum\limits_{m=1}^M \big[A_m \cdot e^{-\frac{(x-\mu_m)^2}{2\cdot\sigma_m^2}}\big] + \epsilon
\end{equation}

```python
%matplotlib inline
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import pymc3 as pm
import arviz as az

#az.style.use('arviz-darkgrid')

print('Running on PyMC3 v{}'.format(pm.__version__))
```

    WARNING (theano.tensor.blas): Using NumPy C-API based implementation for BLAS functions.

    Running on PyMC3 v3.8

## Barplot definitions

```python
def barplot_noise(init_mean, init_std, figure_size=(12,8), fname='barplot_noise', xinfo=''):

    plt.figure(figsize=figure_size, constrained_layout=True)

    # fontsize, labels and title
    fsize = 14
    plt.ylabel('Score', fontweight='bold', fontsize=fsize)
    if xinfo is '':
        plt.title('')
    else:
        plt.title('Convergence by init method + noise level (n=8, obs=30'+ ', ' + xinfo + ')',
                  fontweight='bold', fontsize=fsize)

    # y-limit, barwidth and errorbar size
    ymax = 11
    barWidth = 0.25
    caps = 5
    plt.ylim(0, ymax)
    plt.tick_params(axis="x", labelsize=14)
    plt.tick_params(axis="y", labelsize=14)

    # height of bars
    bars1, err1 = ([init_mean[0][0], init_mean[1][0], init_mean[2][0]],
                   [init_std[0][0], init_std[1][0], init_std[2][0]])
    bars2, err2 = ([init_mean[0][1], init_mean[1][1], init_mean[2][1]],
                   [init_std[0][1], init_std[1][1], init_std[2][1]])
    bars5, err5 = ([init_mean[0][2], init_mean[1][2], init_mean[2][2]],
                   [init_std[0][2], init_std[1][2], init_std[2][2]])

    # set position of bar on X axis
    r1 = np.arange(len(bars1))
    r2 = [x + barWidth for x in r1]
    r5 = [x + barWidth for x in r2]

    # create barplot
    plt.bar(r1, bars1, yerr=err1, capsize=caps, color='burlywood', width=barWidth,
            edgecolor='white', label='1% noise')
    plt.bar(r2, bars2, yerr=err2, capsize=caps, color='peru', width=barWidth,
            edgecolor='white', label='2% noise')
    plt.bar(r5, bars5, yerr=err5, capsize=caps, color='saddlebrown', width=barWidth,
            edgecolor='white', label='5% noise')

    # add xticks on the middle of the group bars
    plt.xlabel('Init Method', fontweight='bold', fontsize=fsize)
    plt.xticks([r + barWidth for r in range(len(bars1))], ['jitter+adapt_diag', 'advi+adapt_diag', 'adapt_diag'])
    plt.legend(loc='upper left', fontsize=fsize)

    # save figure
    plt.savefig(fname + '.png', dpi=150)
```

```python
def barplot_init(init_mean_sum, init_std_sum, figure_size=(12,8), fname='barplot_init', xinfo=''):

    plt.figure(figsize=figure_size, constrained_layout=True)

    # fontsize, labels and title
    fsize = 14
    plt.ylabel('Score', fontweight='bold', fontsize=fsize)
    if xinfo is '':
        plt.title('')
    else:
        plt.title('Convergence by init method (n=8, obs=30'+ ', ' + xinfo + ')', fontweight='bold',
                  fontsize=fsize)

    # y-limit, barwidth and errorbar size
    ymax = 33
    barWidth = 0.25
    caps = 5
    plt.ylim(0, ymax)
    plt.tick_params(axis="x", labelsize=14)
    plt.tick_params(axis="y", labelsize=14)

    # height of bar
    bars, errs = ([init_mean_sum[0], init_mean_sum[1], init_mean_sum[2]],
                  [init_std_sum[0], init_std_sum[1], init_std_sum[2]])

    # position of bar on X axis
    y_pos = np.arange(len(bars))

    # create barplot
    colors = ['burlywood','peru','saddlebrown']
    #colors = ['khaki','darkkhaki','olive']
    plt.bar(y_pos, bars, yerr=errs, capsize=caps, color=colors, edgecolor='white')
    for i in range(len(bars)):
        plt.text(i+.1, y=bars[i]+.5, s="{0:.2f}".format(bars[i]), size=fsize)

    # add xticks on the middle of the group bars
    plt.xlabel('Init Method', fontweight='bold', fontsize=fsize)
    plt.xticks(y_pos, ['jitter+adapt_diag', 'advi+adapt_diag', 'adapt_diag'])

    # save figure
    plt.savefig(fname + '.png', dpi=150)
```

```python
# figure size
figsize=(12,6)
```

## Scenario A: Normal model (peak and no peak information)

```python
# 30 generated spectra (15 samples each) with different noise levels.
# array is filled with number of convergences (rhat < 1.1 & r2 > .99)

# run 8x (8x30=240 spectra total) with peak info passed to model (model NORMAL)
adapt_raw = np.array([[6,6,6,5,5,5,5,6],
                      [6,5,6,4,6,5,4,5],
                      [3,4,4,3,3,4,5,4]])

advi_raw = np.array([[6,7,6,6,6,7,7,6],
                     [4,5,4,2,3,4,4,3],
                     [4,4,4,4,5,4,4,4]])

auto_raw = np.array([[5,3,3,3,2,2,7,3],
                     [3,6,5,3,3,4,5,4],
                     [3,4,4,3,2,4,4,4]])

# run 8x (8x30=240 spectra total) with NO peak info passed to model (model NORMAL)
adapt_raw_np = np.array([[2,3,0,1,1,3,1,2],
                         [0,0,0,0,0,0,0,0],
                         [1,2,3,0,0,0,1,0]])

advi_raw_np = np.array([[0,2,1,0,2,2,2,1],
                        [1,2,1,2,2,2,2,2],
                        [1,0,0,1,1,1,0,1]])

auto_raw_np = np.array([[1,2,1,1,1,3,0,0],
                        [0,2,1,1,0,1,1,0],
                        [1,1,0,0,0,0,0,1]])

# init method means
init_mean = [auto_raw.mean(axis=1),advi_raw.mean(axis=1),adapt_raw.mean(axis=1)]
init_mean_np = [auto_raw_np.mean(axis=1),advi_raw_np.mean(axis=1),adapt_raw_np.mean(axis=1)]

# init method standard deviations
init_std = [auto_raw.std(axis=1),advi_raw.std(axis=1),adapt_raw.std(axis=1)]
init_std_np = [auto_raw_np.std(axis=1),advi_raw_np.std(axis=1),adapt_raw_np.std(axis=1)]

# init method means sum
init_mean_sum = [auto_raw.sum(axis=0).mean(),advi_raw.sum(axis=0).mean(),adapt_raw.sum(axis=0).mean()]
init_mean_sum_np = [auto_raw_np.sum(axis=0).mean(),advi_raw_np.sum(axis=0).mean(),adapt_raw_np.sum(axis=0).mean()]

print(init_mean_sum)
print(init_mean_sum_np)

# init method standard deviations sum
init_std_sum = [auto_raw.sum(axis=0).std(),advi_raw.sum(axis=0).std(),adapt_raw.sum(axis=0).std()]
init_std_sum_np = [auto_raw_np.sum(axis=0).std(),advi_raw_np.sum(axis=0).std(),adapt_raw_np.sum(axis=0).std()]

print(init_std_sum)
print(init_std_sum_np)
```

    [11.125, 14.125, 14.375]
    [2.25, 3.625, 2.5]
    [2.521780125229002, 1.165922381636102, 1.1110243021644486]
    [1.3919410907075054, 1.1110243021644486, 1.224744871391589]

## Covergence by init method and noise level

```python
# normal model with peak information
#barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_norm_peak_noise',
#              xinfo='normal, 0% peakshift')
barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_norm_peak_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, normal, 0% peakshift)')
```

```python
# normal model with NO peak information
#barplot_noise(init_mean_np, init_std_np, figure_size=figsize, fname='sena_norm_nopeak_noise',
#              xinfo='normal, no peakinfo')
barplot_noise(init_mean_np, init_std_np, figure_size=figsize, fname='sena_norm_nopeak_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, normal, no peakinfo)')
```

## Covergence by init method

```python
# normal model with peak information
#barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_norm_peak_init',
#             xinfo='normal, 0% peakshift')
barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_norm_peak_init',
             xinfo='')

print('Convergence by init method (n=8, obs=30, normal, 0% peakshift)')
```

```python
# normal model with NO peak information
#barplot_init(init_mean_sum_np, init_std_sum_np, figure_size=figsize, fname='sena_norm_nopeak_init',
#             xinfo='normal, no peakinfo')
barplot_init(init_mean_sum_np, init_std_sum_np, figure_size=figsize, fname='sena_norm_nopeak_init',
             xinfo='')

print('Convergence by init method (n=8, obs=30, normal, no peakinfo)')
```

## Scenario A: LogNormal model (peak and no peak information)

```python
# 30 generated spectra (15 samples each) with different noise levels.
# array is filled with number of convergences (rhat < 1.1 & r2 > .99)

# run 8x (8x30=240 spectra total) with peak info passed to model (model LOGNORMAL)
adapt_raw = np.array([[9,9,9,9,9,10,10,10],
                      [10,10,10,10,10,10,10,10],
                      [9,9,9,9,9,9,9,9]])

advi_raw = np.array([[8,9,10,9,9,9,9,8],
                     [9,9,10,9,10,9,10,10],
                     [9,8,9,8,8,9,9,8]])

auto_raw = np.array([[4,2,4,5,4,2,1,3],
                     [5,4,2,6,4,4,5,3],
                     [1,5,1,2,2,3,4,4]])

# run 8x (8x30=240 spectra total) with NO peak info passed to model (model LOGNORMAL)
adapt_raw_np = np.array([[0,1,0,0,0,1,0,2],
                         [2,2,0,3,2,1,0,1],
                         [2,0,0,1,1,0,1,0]])

advi_raw_np = np.array([[1,0,2,2,1,1,1,1],
                        [1,1,2,1,1,2,3,0],
                        [2,0,0,3,2,1,0,2]])

auto_raw_np = np.array([[1,0,0,2,0,0,0,0],
                        [2,0,0,2,0,1,2,0],
                        [0,0,0,0,0,0,1,1]])

# init method means
init_mean = [auto_raw.mean(axis=1),advi_raw.mean(axis=1),adapt_raw.mean(axis=1)]
init_mean_np = [auto_raw_np.mean(axis=1),advi_raw_np.mean(axis=1),adapt_raw_np.mean(axis=1)]

# init method standard deviations
init_std = [auto_raw.std(axis=1),advi_raw.std(axis=1),adapt_raw.std(axis=1)]
init_std_np = [auto_raw_np.std(axis=1),advi_raw_np.std(axis=1),adapt_raw_np.std(axis=1)]

# init method means sum
init_mean_sum = [auto_raw.sum(axis=0).mean(),advi_raw.sum(axis=0).mean(),adapt_raw.sum(axis=0).mean()]
init_mean_sum_np = [auto_raw_np.sum(axis=0).mean(),advi_raw_np.sum(axis=0).mean(),adapt_raw_np.sum(axis=0).mean()]

print(init_mean_sum)
print(init_mean_sum_np)

# init method standard deviations sum
init_std_sum = [auto_raw.sum(axis=0).std(),advi_raw.sum(axis=0).std(),adapt_raw.sum(axis=0).std()]
init_std_sum_np = [auto_raw_np.sum(axis=0).std(),advi_raw_np.sum(axis=0).std(),adapt_raw_np.sum(axis=0).std()]

print(init_std_sum)
print(init_std_sum_np)
```

    [10.0, 26.875, 28.375]
    [1.5, 3.75, 2.5]
    [1.5811388300841898, 1.0532687216470449, 0.4841229182759271]
    [1.5, 1.299038105676658, 1.3228756555322954]

## Covergence by init method and noise level

```python
# lognormal model with peak information
#barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_peak_noise',
#              xinfo='lognormal, 0% peakshift')
barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_peak_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, lognormal, 0% peakshift)')
```

```python
# lognormal model with NO peak information
#barplot_noise(init_mean_np, init_std_np, figure_size=figsize, fname='sena_lognorm_nopeak_noise',
#              xinfo='lognormal, no peakinfo')
barplot_noise(init_mean_np, init_std_np, figure_size=figsize, fname='sena_lognorm_nopeak_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, lognormal, no peakinfo)')
```

## Covergence by init method

```python
# lognormal model with peak information
#barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_peak_init',
#             xinfo='lognormal, 0% peakshift')
barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_peak_init',
             xinfo='')

print('Convergence by init method (n=8, obs=30, lognormal, 0% peakshift)')
```

```python
# lognormal model with NO peak information
#barplot_init(init_mean_sum_np, init_std_sum_np, figure_size=figsize, fname='sena_lognorm_nopeak_init',
#            xinfo='lognormal, no peakinfo')
barplot_init(init_mean_sum_np, init_std_sum_np, figure_size=figsize, fname='sena_lognorm_nopeak_init',
            xinfo='')

print('Convergence by init method (n=8, obs=30, lognormal, no peakinfo)')
```

## Scenario A: LogNormal model (peak shift 2%)

```python
# 30 generated spectra (15 samples each) with different noise levels.
# array is filled with number of convergences (rhat < 1.1 & r2 > .99)

# run 8x (8x30=240 spectra total) with peak info passed to model (model LOGNORMAL, 2% peak shift = 4)
adapt_raw = np.array([[10,10,9,9,10,9,9,9],
                      [10,10,10,10,10,9,10,10],
                      [9,9,9,9,9,9,9,9]])

advi_raw = np.array([[10,10,10,9,10,9,8,7],
                     [9,8,8,8,8,8,8,7],
                     [6,8,6,8,6,8,9,8]])

auto_raw = np.array([[0,2,3,3,1,1,2,2],
                     [1,5,2,1,1,2,3,1],
                     [2,1,1,1,2,1,1,2]])

# init method means
init_mean = [auto_raw.mean(axis=1),advi_raw.mean(axis=1),adapt_raw.mean(axis=1)]

# init method standard deviations
init_std = [auto_raw.std(axis=1),advi_raw.std(axis=1),adapt_raw.std(axis=1)]

# init method means sum
init_mean_sum = [auto_raw.sum(axis=0).mean(),advi_raw.sum(axis=0).mean(),adapt_raw.sum(axis=0).mean()]

print(init_mean_sum)

# init method standard deviations sum
init_std_sum = [auto_raw.sum(axis=0).std(),advi_raw.sum(axis=0).std(),adapt_raw.sum(axis=0).std()]

print(init_std_sum)
```

    [5.125, 24.5, 28.25]
    [1.4523687548277813, 1.118033988749895, 0.6614378277661477]

## Covergence by init method and noise level

```python
#barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_shift_2_noise',
#              xinfo='lognormal, 2% peakshift')
barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_shift_2_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, lognormal, 2% peakshift)')
```

## Covergence by init method

```python
#barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_shift_2_init',
#             xinfo='lognormal, 2% peakshift')
barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_shift_2_init',
             xinfo='')

print('Convergence by init method (n=8, obs=30, lognormal, 2% peakshift)')
```

## Scenario A: LogNormal model (peak shift 5%)

```python
# 30 generated spectra (15 samples each) with different noise levels.
# array is filled with number of convergences (rhat < 1.1 & r2 > .99)

# run 8x (8x30=240 spectra total) with peak info passed to model (model LOGNORMAL, 5% peak shift = 10)
adapt_raw = np.array([[9,9,8,8,8,9,9,8],
                      [7,8,6,8,6,9,8,8],
                      [7,8,7,5,7,8,8,7]])

advi_raw = np.array([[7,6,7,6,7,7,7,6],
                     [8,8,8,8,8,8,8,9],
                     [5,6,7,6,7,5,8,5]])

auto_raw = np.array([[1,2,3,2,2,1,0,2],
                     [1,2,3,2,2,2,3,3],
                     [0,1,1,0,1,0,3,2]])

# init method means
init_mean = [auto_raw.mean(axis=1),advi_raw.mean(axis=1),adapt_raw.mean(axis=1)]

# init method standard deviations
init_std = [auto_raw.std(axis=1),advi_raw.std(axis=1),adapt_raw.std(axis=1)]

# init method means sum
init_mean_sum = [auto_raw.sum(axis=0).mean(),advi_raw.sum(axis=0).mean(),adapt_raw.sum(axis=0).mean()]

print(init_mean_sum)

# init method standard deviations sum
init_std_sum = [auto_raw.sum(axis=0).std(),advi_raw.sum(axis=0).std(),adapt_raw.sum(axis=0).std()]

print(init_std_sum)
```

    [4.875, 20.875, 23.125]
    [1.6909686573085854, 1.165922381636102, 1.899835519196333]

## Covergence by init method and noise level

```python
#barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_shift_5_noise',
#              xinfo='lognormal, 5% peakshift')
barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_shift_5_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, lognormal, 5% peakshift)')
```

## Covergence by init method

```python
#barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_shift_5_init',
#             xinfo='lognormal, 5% peakshift')
barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_shift_5_init',
             xinfo='')

print('Convergence by init method (n=8, obs=30, lognormal, 5% peakshift)')
```

## Scenario A: LogNormal model (peak shift 10%)

```python
# 30 generated spectra (15 samples each) with different noise levels.
# array is filled with number of convergences (rhat < 1.1 & r2 > .99)

# run 8x (8x30=240 spectra total) with peak info passed to model (model LOGNORMAL, 10% peak shift = 20)
adapt_raw = np.array([[3,4,2,0,1,5,4,4],
                      [3,6,3,4,3,3,5,1],
                      [4,3,2,2,6,4,4,4]])

advi_raw = np.array([[1,4,2,2,2,3,3,1],
                     [6,4,2,5,5,6,4,4],
                     [2,3,4,2,0,1,3,2]])

auto_raw = np.array([[0,1,0,1,0,0,1,2],
                     [3,1,2,2,0,4,1,1],
                     [1,0,2,1,4,1,1,4]])

# init method means
init_mean = [auto_raw.mean(axis=1),advi_raw.mean(axis=1),adapt_raw.mean(axis=1)]

# init method standard deviations
init_std = [auto_raw.std(axis=1),advi_raw.std(axis=1),adapt_raw.std(axis=1)]

# init method means sum
init_mean_sum = [auto_raw.sum(axis=0).mean(),advi_raw.sum(axis=0).mean(),adapt_raw.sum(axis=0).mean()]

print(init_mean_sum)

# init method standard deviations sum
init_std_sum = [auto_raw.sum(axis=0).std(),advi_raw.sum(axis=0).std(),adapt_raw.sum(axis=0).std()]

print(init_std_sum)
```

    [4.125, 8.875, 10.0]
    [1.3635890143294642, 1.3635890143294642, 2.449489742783178]

## Covergence by init method and noise level

```python
#barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_shift_10_noise',
#              xinfo='lognormal, 10% peakshift')
barplot_noise(init_mean, init_std, figure_size=figsize, fname='sena_lognorm_shift_10_noise',
              xinfo='')

print('Convergence by init method + noise level (n=8, obs=30, lognormal, 10% peakshift)')
```

## Covergence by init method

```python
#barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_shift_10_init',
#             xinfo='lognormal, 10% peakshift')
barplot_init(init_mean_sum, init_std_sum, figure_size=figsize, fname='sena_lognorm_shift_10_init',
             xinfo='')

print('Convergence by init method (n=8, obs=30, lognormal, 10% peakshift)')
```

```python

```


# Neuromatch Academy: Week 1, Day 3, Tutorial 2
# Model Fitting: Linear regression with MLE

**Content creators**: Pierre-Étienne Fiquet, Anqi Wu, Alex Hyafil  with help from Byron Galbraith

**Content reviewers**: Lina Teichmann, Madineh Sarvestani, Patrick Mineault, Ella Batty, Michael Waskom

---
#Tutorial Objectives

This is Tutorial 2 of a series on fitting models to data. We start with simple linear regression, using least squares optimization (Tutorial 1) and Maximum Likelihood Estimation (Tutorial 2). We will use bootstrapping to build confidence intervals around the inferred linear model parameters (Tutorial 3). We'll finish our exploration of regression models by generalizing to multiple linear regression and polynomial regression (Tutorial 4). We end by learning how to choose between these various models. We discuss the bias-variance trade-off (Tutorial 5) and Cross Validation for model selection (Tutorial 6).

In this tutorial, we will use a different approach to fit linear models that incorporates the random 'noise' in our data.
- Learn about probability distributions and probabilistic models
- Learn how to calculate the likelihood of our model parameters
- Learn how to implement the maximum likelihood estimator, to find the model parameter with the maximum likelihood

---
# Setup

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
%config Completer.use_jedi = False
```

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

```python
# @title Helper Functions
def plot_density_image(x, y, theta, sigma=1, ax=None):
    """ Plots probability distribution of y given x, theta, and sigma

    Args:

      x (ndarray): An array of shape (samples,) that contains the input values.
      y (ndarray): An array of shape (samples,) that contains the corresponding
        measurement values to the inputs.
      theta (float): Slope parameter
      sigma (float): standard deviation of Gaussian noise

    """

    # plot the probability density of p(y|x,theta)
    if ax is None:
        fig, ax = plt.subplots()

    xmin, xmax = np.floor(np.min(x)), np.ceil(np.max(x))
    ymin, ymax = np.floor(np.min(y)), np.ceil(np.max(y))
    xx = np.linspace(xmin, xmax, 50)
    yy = np.linspace(ymin, ymax, 50)

    surface = np.zeros((len(yy), len(xx)))
    for i, x_i in enumerate(xx):
        surface[:, i] = stats.norm(theta * x_i, sigma).pdf(yy)

    ax.set(xlabel='x', ylabel='y')

    return ax.imshow(surface, origin='lower', aspect='auto', vmin=0, vmax=None,
                     cmap=plt.get_cmap('Wistia'),
                     extent=[xmin, xmax, ymin, ymax])

def solve_normal_eqn(x, y):
    """Solve the normal equations to produce the value of theta_hat that minimizes
      MSE.

      Args:
      x (ndarray): An array of shape (samples,) that contains the input values.
      y (ndarray): An array of shape (samples,) that contains the corresponding
        measurement values to the inputs.
      theta_hat (float): An estimate of the slope parameter.

    Returns:
      float: The mean squared error of the data with the estimated parameter.
    """
    theta_hat = (x.T @ y) / (x.T @ x)
    return theta_hat
```

---
# Section 1: Maximum Likelihood Estimation (MLE)

```python
#@title Video 1: Maximum Likelihood Estimation
from IPython.display import YouTubeVideo
video = YouTubeVideo(id="8mpNmzLKNfU", width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

In the previous tutorial we made the assumption that the data was drawn from a linear relationship with noise added, and found an effective approach for estimating model parameters based on minimizing the mean squared error.

In that case we treated the noise as simply a nuisance, but what if we factored it directly into our model?

Recall our linear model:

\begin{align}
y = \theta x + \epsilon.
\end{align}

The noise component $\epsilon$ is often modeled as a random variable drawn from a Gaussian distribution (also called the normal distribution).

The Gaussian distribution is described by its [probability density function](https://en.wikipedia.org/wiki/Probability_density_function) (pdf)
\begin{align}
\mathcal{N}(x; \mu, \sigma^2) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{1}{2\sigma^2}(x-\mu)^2}
\end{align}

and is dependent on two parameters: the mean $\mu$ and the variance $\sigma^2$. We often consider the noise signal to be Gaussian "white noise", with zero mean and unit variance:

\begin{align}
\epsilon \sim \mathcal{N}(0, 1).
\end{align}

## Interactive Demo: Gaussian Distribution Explorer

Use the explorer widget below to see how varying the $\mu$ and $\sigma$ parameters change the location and shape of the samples.

```python
# @title

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

@widgets.interact(mu=widgets.FloatSlider(0.0, min=-2.0, max=2.0),
                  sigma=widgets.FloatSlider(1.0, min=0.5, max=2.0))
def plot_normal_dist(mu=0, sigma=1):

    # Generate pdf & samples from normal distribution with mu/sigma
    rv = stats.norm(mu, sigma)
    x = np.linspace(-5, 5, 100)
    y = rv.pdf(x)
    samples = rv.rvs(1000)

    # Plot
    fig, ax = plt.subplots()
    ax.hist(samples, 20, density=True, color='g', histtype='stepfilled', alpha=0.8,
            label='histogram')
    ax.plot(x, y, color='orange', linewidth=3, label='pdf')
    ax.vlines(mu, 0, rv.pdf(mu), color='y', linewidth=3, label='$\mu$')
    ax.vlines([mu-sigma, mu+sigma], 0, rv.pdf([mu-sigma, mu+sigma]), colors='red',
              color='b', linewidth=3, label='$\sigma$')
    ax.set(xlabel='x', ylabel='probability density',
           xlim=[-5, 5], ylim=[0, 1.0])
    ax.legend()
```

    interactive(children=(FloatSlider(value=0.0, description='mu', max=2.0, min=-2.0), FloatSlider(value=1.0, desc…

## Section 1.1: Probabilistic Models

Now that we have a model of our noise component $\epsilon$ as random variable, how do we incorporate this back into our original linear model from before? Consider again our simplified model $y = \theta x + \epsilon$ where the noise has zero mean and unit variance $\epsilon \sim \mathcal{N}(0, 1)$. We can now also treat $y$ as a random variable drawn from a Gaussian distribution where $\mu = \theta x$ and $\sigma^2 = 1$:

\begin{align}
y \sim \mathcal{N}(\theta x, 1)
\end{align}

which is to say that the probability of observing $y$ given $x$ and parameter $\theta$ is
\begin{align}
p(y|x,\theta) = \frac{1}{\sqrt{2\pi}}e^{-\frac{1}{2}(y-\theta x)^2}
\end{align}

---

Let's revisit our original sample dataset where the true underlying model has $\theta = 1.2$.

```python
# @title

# @markdown Execute this cell to generate some simulated data

# setting a fixed seed to our random number generator ensures we will always
# get the same psuedorandom number sequence

np.random.seed(121)
theta = 1.2
n_samples = 30
x = 10 * np.random.rand(n_samples) # sample from a uniform distribution over [0,10)
noise = np.random.randn(n_samples) # sample from a standard normal distribution
y = theta * x + noise
```

This time we can plot the density of $p(y|x,\theta=1.2)$ and see how $p(y)$ changes for different values of $x$.

```python
#@title
#@markdown Execute this cell to visualize p(y|x, theta=1.2)

fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(10, 4))

# Invokes helper function to generate density image plots from data and parameters
im = plot_density_image(x, y, 1.2, ax=ax1)
plt.colorbar(im, ax=ax1)
ax1.axvline(8, color='k')
ax1.set(title=r'p(y | x, $\theta$=1.2)')

# Plot pdf for given x
ylim = ax1.get_ylim()
yy = np.linspace(ylim[0], ylim[1], 50)
ax2.plot(yy, stats.norm(theta * 8, 1).pdf(yy), color='orange', linewidth=2)
ax2.set(
    title=r'p(y|x=8, $\theta$=1.2)',
    xlabel='y',
    ylabel='probability density');
```

## Section 1.2: Likelihood Estimation

Now that we have our probabilistic model, we turn back to our original challenge of finding a good estimate for $\theta$ that fits our data. Given the inherent uncertainty when dealing in probabilities, we talk about the [likelihood](https://en.wikipedia.org/wiki/Likelihood_function) that some estimate $\hat \theta$ fits our data. The likelihood function $\mathcal{L(\theta)}$ is equal to the probability density function parameterized by that $\theta$:

\begin{align}
\mathcal{L}(\theta|x,y) = p(y|x,\theta) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{1}{2\sigma^2}(y-\theta x)^2}
\end{align}

### Exercise 1: Likelihood Function

In this exercise you will implement the likelihood function $\mathcal{L}(\theta|x,y)$ for our linear model where $\sigma = 1$.

After implementing this function, we can produce probabilities that our estimate $\hat{\theta}$ generated the provided observations. We will try with one of the samples from our dataset.

TIP: Use `np.exp` and `np.sqrt` for the exponential and square root functions, respectively.

```python
def likelihood(theta_hat, x, y):
    """The likelihood function for a linear model with noise sampled from a
      Gaussian distribution with zero mean and unit variance.

    Args:
      theta_hat (float): An estimate of the slope parameter.
      x (ndarray): An array of shape (samples,) that contains the input values.
      y (ndarray): An array of shape (samples,) that contains the corresponding
        measurement values to the inputs.

    Returns:
      ndarray: the likelihood values for the theta_hat estimate
    """
    sigma = 1

    # Compute Gaussian likelihood
    pdf = 1 / np.sqrt(2 * np.pi * sigma**2) * np.exp(-(y - theta_hat * x)**2 / (2 * sigma**2))

    return pdf

# Uncomment below to test your function
print(likelihood(1.0, x[1], y[1]))
```

    0.11344443599846923

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W1D3_ModelFitting/solutions/W1D3_Tutorial2_Solution_328a0c30.py)

We should see that $\mathcal{L}(\theta=1.0|x=2.1,y=3.7) \approx 0.11$. So far so good, but how does this tell us how this estimate is better than any others?

When dealing with a set of data points, as we are with our dataset, we are concerned with their joint probability -- the likelihood that all data points are explained by our parameterization. Since we have assumed that the noise affects each output independently, we can factorize the likelihood, and write:

\begin{align}
\mathcal{L}(\theta|X,Y) = \prod_{i=1}^N \mathcal{L}(\theta|x_i,y_i),
\end{align}

where we have $N$ data points $X = \{x_1,...,x_N\}$ and $Y = \{y_1,...,y_N\}$.

In practice, such a product can be numerically unstable. Indeed multiplying small values together can lead to [underflow](https://en.wikipedia.org/wiki/Arithmetic_underflow), the situation in which the digital representation of floating point number reaches its limit. This problem can be circumvented by taking the logarithm of the likelihood because the logarithm transforms products into sums:

\begin{align}
\operatorname{log}\mathcal{L}(\theta|X,Y) = \sum_{i=1}^N \operatorname{log}\mathcal{L}(\theta|x_i,y_i)
\end{align}

We can take the sum of the log of the output of our `likelihood` method applied to the full dataset to get a better idea of how different $\hat\theta$ compare. We can also plot the different distribution densities over our dataset and see how they line up qualitatively.

```python
# @title
# @markdown Execute this cell to visualize different distribution densities

theta_hats = [0.5, 1.0, 2.2]
fig, axes = plt.subplots(ncols=3, figsize=(16, 4))
for theta_hat, ax in zip(theta_hats, axes):
    ll = np.sum(np.log(likelihood(theta_hat, x, y)))  # log likelihood
    im = plot_density_image(x, y, theta_hat, ax=ax)
    ax.scatter(x, y)
    ax.set(title=fr'$\hat{{\theta}}$ = {theta_hat}, log likelihood: {ll:.2f}')
plt.colorbar(im, ax=ax);
```

Using the log likelihood calculation, we see that $\mathcal{L}(\theta=1.0) > \mathcal{L}(\theta=0.5) > \mathcal{L}(\theta=2.2)$.

This is great: now we have a way to compare estimators based on likelihood. But like with the MSE approach, we want an analytic solution to find the best estimator. In this case, we want to find the estimator that maximizes the likelihood.

## Section 1.3: Finding the Maximum Likelihood Estimator

We want to find the parameter value $\hat\theta$ that makes our data set most likely:

\begin{align}
\hat{\theta}_{\textrm{MLE}} = \underset{\theta}{\operatorname{argmax}} \mathcal{L}(\theta|X,Y)
\end{align}

We discussed how taking the logarithm of the likelihood helps with numerical stability, the good thing is that it does so without changing the parameter value that maximizes the likelihood. Indeed, the $\textrm{log}()$ function is *monotonically increasing*, which means that it preserves the order of its inputs. So we have:

\begin{align}
\hat{\theta}_{\textrm{MLE}} = \underset{\theta}{\operatorname{argmax}} \sum_{i=1}^m \textrm{log} \mathcal{L}(\theta|x_i,y_i)
\end{align}

Now substituting our specific likelihood function and taking its logarithm, we get:
\begin{align}
\hat{\theta}_{\textrm{MLE}} = \underset{\theta}{\operatorname{argmax}} [-\frac{N}{2} \operatorname{log} 2\pi\sigma^2 - \frac{1}{2\sigma^2}\sum_{i=1}^N (y_i-\theta x_i)^2].
\end{align}

Note that maximizing the log likelihood is the same as minimizing the negative log likelihood (in practice optimization routines are developed to solve minimization not maximization problems). Because of the convexity of this objective function, we can take the derivative of our negative log likelihhood, set it to 0, and solve - just like our solution to minimizing MSE.

\begin{align}
\frac{\partial\operatorname{log}\mathcal{L}(\theta|x,y)}{\partial\theta}=\frac{1}{\sigma^2}\sum_{i=1}^N(y_i-\theta x_i)x_i = 0
\end{align}

This looks remarkably like the equation we had to solve for the optimal MSE estimator, and, in fact, we arrive to the exact same solution!

\begin{align}
\hat{\theta}_{\textrm{MLE}} = \hat{\theta}_{\textrm{MSE}} = \frac{\sum_{i=1}^N x_i y_i}{\sum_{i=1}^N x_i^2}
\end{align}

```python
# Compute theta_hat_MLE
theta_hat_mle = (x @ y) / (x @ x)
```

```python
#@title
#@markdown Execute this cell to visualize density with theta_hat_mle

# Plot the resulting distribution density
fig, ax = plt.subplots()
ll = np.sum(np.log(likelihood(theta_hat_mle, x, y))) # log likelihood
im = plot_density_image(x, y, theta_hat_mle, ax=ax)
plt.colorbar(im, ax=ax);
ax.scatter(x, y)
ax.set(title=fr'$\hat{{\theta}}$ = {theta_hat_mle:.2f}, log likelihood: {ll:.2f}');
```

---
# Summary

- Likelihood vs probability
    - $\mathcal{L}(\theta|x, y) = p(y|\theta, x)$
    - $p(y|\theta, x)$ -> "probability of observing the response $y$ given parameter $\theta$ and input $x$"
    - $\mathcal{L}(\theta|x, y)$ -> "likelihood model that parameters $\theta$ produced response $y$ from input $x$"
- Log-likelihood maximization
    - We take the $\textrm{log}$ of the likelihood function for computational convenience
    - The parameters $\theta$ that maximize $\textrm{log}\mathcal{L}(\theta|x, y)$ are the model parameters that maximize the probability of observing the data.
- **Key point**:
  - the log-likelihood is a flexible cost function, and is often used to find model parameters that best fit the data.

---
# Appendix

We can also see $\mathrm{p}(\mathrm{y} | \mathrm{x}, \theta)$ as a function of $x$. This is the stimulus likelihood function, and it is useful in case we want to decode the input $x$ from observed responses $y$. This is what is relevant from the point of view of a neuron that does not have access to the outside world and tries to infer what's out there from the responses of other neurons!


# Waves

The solution to the Riemann problem is built out of *waves*. These are regions where the variables change, separating constant states. There are two basic types of wave: those across which the variables change continuously (which, for fluid problems, are known as *rarefactions*), and those across which the variables change discontinuously (which break down into more subclasses).

We will assume that the state is known on one side of the wave, which will be denoted ${\bf U}_k$. The state on the other side will be unknown. Given the value of one variable, typically the pressure $p$, on the unknown side, the solution across the wave can be found.

The equations here are adapted from the [Living Review of Martí and Müller](http://computastrophys.livingreviews.org/Articles/lrca-2015-3/articlese8.html#x11-1150008.5). The approach to reactive waves follows the [paper of Zhang and Zheng][1], extended to the Relativistic case.

[1]: http://dx.doi.org/10.1016/0022-0396(89)90142-3

## Wave sections

In general any wave can be built from multiple sections, pieced together. A wave may "split" into two discontinuous pieces moving at different speeds. A wave may contain a discontinuous section attached to a continuous section. In the most complex cases, the wave may have an arbitrarily large number of sections.

In the inert case, and when the equation of state is convex, each wave contains a single section. If the pressure decreases across the wave, $p_k > p$, then the wave is a continuous rarefaction. If the pressure increases across the wave, $p_k < p$, then the wave is a discontinuous shock. If the pressure does not change, the wave is either trivial (the state does not change) or a contact discontinuity (where quantities except $p$ and $v_x$ may jump).

In the reactive case, all the reactions that we can model will take place "instantly". This means that reactions must take place across discontinuous wave sections. The reactive waves may now contain multiple wave sections. If the equation of state is convex, and the reaction exothermic, then across a reactive discontinuous wave the pressure increases. Therefore if the pressure is to decrease across the wave as a whole, there must be an inert *precursor* shock before the reactive discontinuity.

Finally, we note that not all reactive discontinuous waves are stable. This can be checked by looking at the wave speed on the unknown side of the wave. If the wave speed leads to characteristics coming *out* of the discontinuity, then the wave is unstable. In this case we need to add another wave section. We find the fastest stable reactive discontinuity (a *Chapman-Jouget* discontinuity), where the wave speed matches the speed of the discontinuity, and attach to it an inert rarefaction.

## Rarefactions

Across a rarefaction the normal velocity satisfies

$$
  \frac{\text{d} v_x}{\text{d} p} = \pm \frac{1}{\rho_0 h W^2 c_s} \frac{1}{\sqrt{1 + g \left( \xi_{\pm}, v_x, v_t \right)}}.
$$

The sign corresponds to the wavenumber: plus for the right acoustic wave and minus for the left. In the code this corresponds to `lr_sign = wavenumber - 1`.

The function $g$ quantifies the effect of the tangential velocity, and is

$$
  g = \frac{\left( v_t \right)^2 \left( \xi_{\pm} - 1 \right)}{\left( 1 - \xi_{\pm} v_x \right)^2}.
$$

The wavespeed itself is $\xi_{\pm}$.

We also solve for the rest mass density and specific internal energy across the wave using

$$
  \frac{\text{d}}{\text{d} p} \begin{pmatrix} \rho_0 \\ \epsilon \end{pmatrix} = \begin{pmatrix} \frac{1}{h c_s^2} \\ \frac{p}{\rho_0^2 h c_s^2} \end{pmatrix}.
$$

Having solved across the wave, the tangential velocity follows as

$$
  v_t = h_k W_k \left( v_t \right)_k \frac{1 - v_x^2}{h^2 + \left( h_k W_k \left( v_t \right)_k \right)^2}.
$$

The relation for the tangential velocity holds across both continuous and discontinuous waves.

## Shocks

Across an inert shock we first find the post shock density and enthalpy by solving the nonlinear equation

$$
  h^2 - h_k^2 - \left( \frac{h}{\rho_0} + \frac{h_k}{\left( \rho_0 \right)_k} \right) \left( p - p_k \right) = 0.
$$

This assumes the post shock pressure $p_k$ is known, and that the equation of state gives the enthalpy $h_k = h \left( \left( \rho_0 \right)_k, p_k \right)$.

From this we can compute the mass flux $j$ across the shock, as

$$
  j = \sqrt{ \frac{p_k - p}{ \frac{h^2 - h_k^2}{p - p_k} - \frac{2 h_k}{p_k} } }.
$$

This gives the shock velocity as

$$
  v_S = \pm \frac{ \left( \rho_0 \right)_k^2 W_k^2 \left( v_x \right)_k \pm j^2 \sqrt{ 1 + \frac{\left( \rho_0 \right)_k^2 W_k^2 \left( 1 - \left( v_x \right)^2 \right)}{j^2}}}{\left( \rho_0 \right)_k^2 W_k^2 + j^2}.
$$

Again, the sign corresponds to the wave number.

Given the shock velocity we can compute the shock "Lorentz factor" $W_S = (1 - v_S^2)^{-1/2}$, from which the post shock normal velocity is

$$
  v_x = \left( h_k W_k v_S \pm \frac{p - p_k}{j \sqrt{1 - v_S^2}} \right) \left[ h_k W_k + \left( p - p_k \right) \left( \frac{1}{\left( \rho_0 \right)_k W_k} \pm \frac{\left( v_x \right)_k W_S}{j} \right) \right]^{-1}.
$$

The tangential velocity follows as in the rarefaction case.

## Detonation

A detonation is a discontinuous reactive wave section across which the pressure increases. The equations are identical to those for the shock case, but the interpretation changes. All "known" (pre shock) variables have the reactive equation of state. All "unknown" (post shock) variables use the inert equation of state - the reaction has taken place across the discontinuity.

As noted above, it is possible for the resulting detonation wave section to be unstable. In general, detonations fall into two classes: *weak* detonations (which are unstable) and *strong* detonations (which are stable). For the stable strong detonations the characteristic waves impinge on the discontinuity from both sides. For the unstable weak detonations the characteristics only enter the discontinuity on one side.

If an unstable weak detonation is found, the section should be replaced by the fastest detonation that is stable. This Chapman-Jouget, or CJ detonation, is where the characteristic wave is parallel to the discontinuity. In this case, the post detonation pressure will not match the required post wave pressure, and an additional rarefaction wave section is needed to complete the wave.

## Deflagration

A deflagration is a discontinuous wave section across which the pressure decreases. As a reaction cannot take place across a rarefaction wave, this requires a discontinuity. However, this discontinuity will reduce the temperature along with the pressure. This means that, unless the material was already at the right temperature to react, any reaction across this wave would be unphysical.

The solution is to start with an inert *precursor* shock which raises the temperature of the material to the ignition temperature. This first section follows the exact equations in the shock section above. Next, there is a deflagration wave section, across which the reaction takes place and the pressure drops. Again, this follows the shock equations, but with the same interpretation as in the detonation case.

Again, the deflagration wave section need not be stable. As with detonations, deflagrations fall into two classes: *weak* deflagrations (which are stable), and *strong* deflagrations (which are unstable). For the stable weak deflagrations the characteristic waves from one side impinge on the discontinuity, but not the other. For the unstable strong deflagrations, neither set of characteristic waves impinges on the discontinuity.

Again, if an unstable strong deflagration is found it is replaced with a CJ deflagration, and the full wave is completed with a rarefaction wave section.

## Code

The `Wave` class in `r3d2` in intended for internal use, but can be used to construct single waves directly. We need a known state with an equation of state first:

```python
from r3d2 import eos_defns, State, wave
```

```python
eos = eos_defns.eos_gamma_law(5.0/3.0)
U = State(1.0, 0.1, 0.05, 0.2, eos, label="known")
U
```

\begin{equation}\begin{pmatrix} \rho \\ v_x \\ v_t \\ \epsilon \end{pmatrix}_{known} = \begin{pmatrix} 1.0000 \\ 0.1000 \\ 0.0500 \\ 0.2000 \end{pmatrix}\end{equation}

We then solve for a left going (wavenumber `0`) inert acoustic wave with post-wave pressure of $10$ or $0.1$:

```python
wave_1 = wave.Wave(U, unknown_value=10, wavenumber=0)
wave_2 = wave.Wave(U, unknown_value=0.1, wavenumber=0)
```

The first wave is a shock. When output in the notebook, it gives the type, wave number, and wave speed:

```python
wave_1
```

\begin{equation}{\cal S}_{\leftarrow}: \lambda^{(0)}= -0.9614\end{equation}

The second wave is a rarefaction. In this case, as it's a continuous wave, its wave speed is a range:

```python
wave_2
```

\begin{equation}{\cal R}_{\leftarrow}: \lambda^{(0)}\in [-0.3209, -0.2383]\end{equation}

The right going (wavenumber `2`) acoustic waves follow the same structure:

```python
wave_right = wave.Wave(U, unknown_value=100, wavenumber=2)
wave_right
```

\begin{equation}{\cal S}_{\rightarrow}: \lambda^{(2)}= 0.9978\end{equation}

The advective waves are different. The pressure must be constant across the wave, so if anything jumps it must be related to the state: either the thermodynamic variables or the equation of state:

```python
eos2 = eos_defns.eos_gamma_law(4.0/3.0)
U2 = State(2.0, U.v, -0.3, U.p/2.0*3.0, eos2)
wave_contact = wave.Wave(U, unknown_value=U2, wavenumber=1)
wave_contact
```

\begin{equation}{\cal C}: \lambda^{(1)}= 0.1000\end{equation}

For a reactive wave, we first need a reactive equation of state:

```python
eos_reactive = eos_defns.eos_gamma_law_react(5.0/3.0, 0.1, 1.0, 1.0, eos)
U_reactive = State(5.0, 0.0, 0.0, 2.0, eos_reactive)
U_reactive
```

\begin{equation}\begin{pmatrix} \rho \\ v_x \\ v_t \\ \epsilon \\ q \end{pmatrix}= \begin{pmatrix} 5.0000 \\ 0.0000 \\ 0.0000 \\ 2.0000 \\ 0.1000 \end{pmatrix}\end{equation}

We can now connect this to states with large ($p=10$) and small ($p=0.1$) pressures again:

```python
wave_1_reactive = wave.Wave(U_reactive, unknown_value=10, wavenumber=0)
wave_2_reactive = wave.Wave(U_reactive, unknown_value=0.1, wavenumber=0)
```

The analogue of the inert shock is the strong detonation:

```python
wave_1_reactive
```

\begin{equation}{\cal SDT}_{\leftarrow}: \lambda^{(0)}= -0.7979\end{equation}

The analogue of the rarefaction is the weak deflagration:

```python
wave_2_reactive
```

\begin{equation}\left({\cal CJDF}_{\leftarrow}{\cal R}_{\leftarrow}\right) : \lambda^{(0)}\in [-0.6097, 0.7607]\end{equation}

In this case we have a CJ deflagration, which is attached to a rarefaction to complete the wave.


# Non-ideal equations of state
This example demonstrates a comparison between ideal and non-ideal equations of state (EoS) using Cantera and CoolProp. The following equations of state are used to evaluate thermodynamic properties in this example
1. Ideal-gas EoS from Cantera
2. Non-ideal Redlich-Kwong EoS from Cantera
3. Helmholtz energy EoS from CoolProp

#### Import required packages (Cantera and CoolProp)
[CoolProp](http://coolprop.org) [1] is an open-source package that contains a highly-accurate database for thermophysical properties. The thermodynamic properties are obtained using pure and pseudo-pure fluid equations of state implemented for 122 components.

>1. I.H. Bell, J.Wronski, S. Quoilin, V. Lemort, 'Pure and Pseudo-pure Fluid Thermophysical Property Evaluation and the Open-Source Thermophysical Property Library CoolProp,' Industrial & Engineering Chemistry Research 53 (2014), https://pubs.acs.org/doi/10.1021/ie4033999

```python
# Import Cantera
import cantera as ct
import numpy as np
%matplotlib notebook
import matplotlib.pyplot as plt
import time
print(f'Running Cantera version: {ct.__version__}')
from CoolProp.CoolProp import PropsSI
```

    Running Cantera version: 2.6.0a2

### Helper functions
This example uses CO$_2$ as the only species. The function `get_thermo_Cantera` calculates thermodynamic properties based on the thermodynamic state ($T$, $p$) of the species using Cantera. Applicable phases are `Ideal-gas` and `Redlich-Kwong`. The ideal-gas equation can be stated as
\begin{equation}
    pv = RT,
\end{equation}
where $p$, $v$ and $T$ represent thermodynamic pressure, molar volume, and the temperature of the gas-phase. $R$ is the universal gas constant.

The function `get_thermo_CoolProp` utilizes the CoolProp package to evaluate thermodynamic properties based on the gas-phase, thermodynamic state ($T$, $p$) for a given fluid. The standard-reference thermodynamic states are different for Cantera and CoolProp, it is necessary to convert these values in an appropriate scale before comparison. Therefore, both functions `get_thermo_Cantera` and `get_thermo_CoolProp` return the thermodynamic values relative to the reference state obtained at 1 Bar, 300 K.

To plot the comparison of thermodynamic properties among three EoS, the `plot` function is used.

```python
def get_thermo_Cantera(gas, T, p) :

    states = ct.SolutionArray(gas, len(p))
    X = 'CO2:1.0'
    states.TPX = T, p, X

    u = states.u
    h = states.h
    s = states.s
    cp = states.cp
    cv = states.cv

    # Get the relative enthalpy, entropy and int. energy with referance to the first point
    u = u - u[0]
    s = s - s[0]
    h = h - h[0]

    return  h, u, s, cp, cv

def get_thermo_CoolProp(T, p) :

    n = len(p)
    u = np.zeros(n)
    h = np.zeros(n)
    s = np.zeros(n)
    cp = np.zeros(n)
    cv = np.zeros(n)

    for i in range(n):

        #PropsSI("T","P",101325,"Q",0,"REFPROP::CO2")

        u[i] = PropsSI("U","P", p[i],"T", T,"HEOS::CO2")     # kJ/kg/K
        h[i] = PropsSI("H","P", p[i],"T", T,"HEOS::CO2")
        s[i] = PropsSI("S","P", p[i],"T", T,"HEOS::CO2")
        cp[i] = PropsSI("C","P", p[i],"T", T,"HEOS::CO2")
        cv[i] = PropsSI("O","P", p[i],"T", T,"HEOS::CO2")

    # Get the relative enthalpy, entropy and int. energy with referance to the first point
    u = u - u[1]
    s = s - s[1]
    h = h - h[1]

    return  h, u, s, cp, cv

def plot(T, p, a_Ideal, a_RK, a_CoolProp, name) :

    line_width = 3
    n = len(p)

    plt.figure()
    plt.plot(p/1e5, a_Ideal,'-',color='b', linewidth = line_width)
    plt.plot(p/1e5, a_RK,'-',color='r', linewidth = line_width)
    plt.plot(p/1e5, a_CoolProp,'-',color='k', linewidth = line_width)
    plt.xlabel('Pressure [Bar]')
    plt.ylabel(name)
    plt.legend(['Ideal EoS','RK EoS', 'CoolProp'], prop={'size': 14}, frameon=False)
```

## 1. EoS Comparison based on thermodynamic properties

This is the main subroutine that compares and plots the thermodynamic values obtained using three equations of state.

```python
# Main function

# Input parameters
T = 300                               # Temperature is constant [unit:K]
p = 1e5*np.linspace(1, 100, 1000)     # Pressure is varied from 1 to 100 bar [unit:Pa]

# Read the ideal gas-phase
gasIdeal = ct.Solution('data/co2-thermo.yaml', 'CO2-Ideal', transport=None)
[hIdeal, uIdeal, sIdeal, cpIdeal, cvIdeal] = get_thermo_Cantera(gasIdeal, T, p)

# Read the R-K gas-phase
gasRK = ct.Solution('data/co2-thermo.yaml', 'CO2-RK', transport=None)
[hRK, uRK, sRK, cpRK, cvRK] = get_thermo_Cantera(gasRK, T, p)

# Read the thermo data using CoolProp
[hCoolProp, uCoolProp, sCoolProp, cpCoolProp, cvCoolProp] = get_thermo_CoolProp(T, p)
```

```python
# Plot the result

# Internal energy
plot(T, p, uIdeal, uRK, uCoolProp, "Relative Internal Energy [J/kg]")
# Enthalpy
plot(T, p, hIdeal, hRK, hCoolProp, "Relative Enthalpy [J/kg]")
# Entropy
plot(T, p, sIdeal, sRK, sCoolProp, "Relative Entropy [J/K]")
```

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

The thermodynamic properties such as internal energy, enthalpy, and entropy are plotted against the operating pressure at a constant temperature $T = 300$ K. The three equations follow each other closely at low pressures ($P < 10$ Bar). However, the ideal gas EoS departs significantly from the observed behavior of gases near the critical regime ($P_{\rm {crit}} = 73.77$ Bar).

The ideal gas EoS does not consider inter-molecular interactions and the volume occupied by individual gas particles. At low temperatures and high pressures, inter-molecular forces become particularly significant due to a reduction in inter-molecular distances. Additionally, at high pressures, the volume of individual molecules becomes significant. Both of these factors contribute to the deviation from ideal behavior at high pressures. The cubic Redlich-Kwong EoS, on the other hand, predicts thermodynamic properties accurately near the critical regime.

```python
# Specific heat at constant pressure
plot(T, p, cpIdeal, cpRK, cpCoolProp, "Cp [J/kg/K]")
# Specific heat at constant volume
plot(T, p, cvIdeal, cvRK, cvCoolProp, "Cv [J/kg/K]")
```

    <IPython.core.display.Javascript object>

    <IPython.core.display.Javascript object>

In the case of Ideal gas EoS, the specific heats at constant pressure ($C_{\rm p}$) and constant volume ($C_{\rm v}$) are independent of the pressure. Hence, $C_{\rm p}$ and $C_{\rm v}$ for ideal EoS do not change as the pressure is varied from $1$ Bar to $100$ Bar in this study.

Specific heat at constant pressure ($C_{\rm p}$) for R-K EoS follow the trend closely with the Helmholtz EoS to the critical regime. Although the specific heat at constant pressure ($C_{\rm p}$) shows reasonable agreement with the Helmholtz EoS in sub-critical and supercritical regimes, it inaccurately predicts a very high value near the critical point. The heat capacity at constant pressure ($C_{\rm p}$) at the critical point is finite. The sudden rise in $C_{\rm p}$ value in the case of R-K EoS is just a numerical artifact and not a real singularity.

The specific heat at constant volume ($C_{\rm v}$), on the other hand, predicts smaller values in the subcritical and critical regime. However, it shows completely wrong values in the super-critical region, making it invalid at very high pressures. It is well known that the cubic equations typically fail to predict accurate constant-volume heat capacity in the transcritical region [2]. Certain cubic EoS models have been extended to resolve this discrepancy using crossover models. For further information, see the work of Span [2] and Saeed et al [3].

>2. R. Span, 'Multiparameter Equations of State - An Accurate Source of Thermodynamic Property Data', Springer Berlin Heidelberg (2000), http://dx.doi.org/10.1007/978-3-662-04092-8
>3. A. Saeed, S. Ghader,'Calculation of density, vapor pressure and heat capacity near the critical point by incorporating cubic SRK EoS and crossover translation', Fluid Phase Equilibria (2019) 493, https://doi.org/10.1016/j.fluid.2019.03.027

## 2. Temperature-Density plots

Following function plots the $T-\rho$ diagram over a wide pressure and temperature range. The temperature is varied from 250 K to 400 K. The pressure is changed from 1 Bar to 600 Bar.

```python
# Input parameters
# Set up arrays for pressure and temperature
p_array = [1] + [i for i in np.arange(10,101,30)] + [i for i in np.arange(200,601,100)]     # Pressure is varied from 1 to 600 bar
T_array = [i for i in np.arange(250,401,10)]     # Temperature is varied from 250K to 400K

# Read ideal and non-ideal phases
gasIdeal = ct.Solution('data/co2-thermo.yaml', 'CO2-Ideal', transport=None)       # ideal gas-phase
gasRK = ct.Solution('data/co2-thermo.yaml', 'CO2-RK', transport=None)             # R-K gas-phase

# Loop over temperature and pressure
density_coolprop = np.zeros(len(T_array))

# Plot
plt.rcParams['axes.labelsize'] = 14
plt.figure(figsize=(10,5))
cmap= plt.get_cmap()
colors = iter(cmap(np.linspace(0,1,len(p_array))))

for i in range(len(p_array)):
    p = 1e5* p_array[i]

    states = ct.SolutionArray(gasIdeal, len(T_array))
    states.TP = T_array, p
    density_Ideal = states.density_mass

    states = ct.SolutionArray(gasRK, len(T_array))
    states.TP = T_array, p
    density_RK = states.density_mass

    for j in range(len(T_array)):
        density_coolprop[j] = PropsSI('D','P', p,'T',T_array[j],'HEOS::CO2')

    colr = next(colors)
    pBar = p/1e5;
    plt.plot(density_Ideal, T_array,'--',color=colr, label= pBar)
    plt.plot(density_RK, T_array,'o',color=colr)
    plt.plot(density_coolprop, T_array,'-',color=colr)

plt.text(-50, 320, 'p = 1 [Bar]', color='darkblue', rotation = 'vertical', size = '14')
plt.text(300, 308, 'p = 70 [Bar]', color= 'c', size = '14', rotation = '-5')
plt.text(950, 320, 'p = 600 [Bar]', color=colr, size = '14', rotation = '-55')
plt.xlabel('Density [kg/m3]')
plt.ylabel('Temperature [K]')
plt.legend(['Ideal EoS','RK EoS', 'CoolProp'], prop={'size': 14}, frameon=False)
```

    <IPython.core.display.Javascript object>

    <matplotlib.legend.Legend at 0x17eeee69bb0>

The figure compares $T-\rho$ plots for ideal, R-K, and Helmholtz EoS at different operating pressures. All three EoS yield the same plots at low pressures (0 Bar and 10 Bar). However, the Ideal gas EoS departs significantly at high pressures ($P > 10$ Bar), where non-ideal effects are prominent. The R-K EoS closely matches the Helmholtz EoS at supercritical pressures ($P \ge 70$ Bar). However, it does depart in the liquid-vapor region that exists at $P < P_{\rm {crit}}$ and low temperatures (~$T_{\rm {crit}}$).

```python

```


#Importing Libraries

```
!nvidia-smi
```

    Fri Feb  7 15:16:08 2020
    +-----------------------------------------------------------------------------+
    | NVIDIA-SMI 440.48.02    Driver Version: 418.67       CUDA Version: 10.1     |
    |-------------------------------+----------------------+----------------------+
    | GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
    | Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
    |===============================+======================+======================|
    |   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
    | N/A   53C    P8    11W /  70W |      0MiB / 15079MiB |      0%      Default |
    +-------------------------------+----------------------+----------------------+

    +-----------------------------------------------------------------------------+
    | Processes:                                                       GPU Memory |
    |  GPU       PID   Type   Process name                             Usage      |
    |=============================================================================|
    |  No running processes found                                                 |
    +-----------------------------------------------------------------------------+

```
import numpy as np
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
from IPython import display
plt.style.use('seaborn-white')
```

# Read and process data.

Download the file from this URL: https://drive.google.com/file/d/1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS/view?usp=sharing

```
!wget -O text.txt https://drive.google.com/u/0/uc?id=1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS&export=download
```

    --2020-02-07 15:14:53--  https://drive.google.com/u/0/uc?id=1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS
    Resolving drive.google.com (drive.google.com)... 108.177.126.139, 108.177.126.100, 108.177.126.101, ...
    Connecting to drive.google.com (drive.google.com)|108.177.126.139|:443... connected.
    HTTP request sent, awaiting response... 302 Moved Temporarily
    Location: https://doc-14-3o-docs.googleusercontent.com/docs/securesc/ha0ro937gcuc7l7deffksulhg5h7mbp1/0elsrch4rfoehh4a1decjc7ue2igh137/1581087600000/02008525212197398114/*/1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS [following]
    Warning: wildcards not supported in HTTP.
    --2020-02-07 15:14:58--  https://doc-14-3o-docs.googleusercontent.com/docs/securesc/ha0ro937gcuc7l7deffksulhg5h7mbp1/0elsrch4rfoehh4a1decjc7ue2igh137/1581087600000/02008525212197398114/*/1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS
    Resolving doc-14-3o-docs.googleusercontent.com (doc-14-3o-docs.googleusercontent.com)... 108.177.119.132, 2a00:1450:4013:c00::84
    Connecting to doc-14-3o-docs.googleusercontent.com (doc-14-3o-docs.googleusercontent.com)|108.177.119.132|:443... connected.
    HTTP request sent, awaiting response... 200 OK
    Length: 10346 (10K) [text/plain]
    Saving to: ‘text.txt’

    text.txt            100%[===================>]  10.10K  --.-KB/s    in 0s

    2020-02-07 15:14:59 (85.9 MB/s) - ‘text.txt’ saved [10346/10346]

```
data = open('text.txt', 'r').read()
```

Process data and calculate indices

```
chars = list(set(data)) # List of Set of Unique characaters used in the text

data_size, X_size = len(data), len(chars)
print("Corona Virus article has %d characters, %d unique characters" %(data_size, X_size))

#Forming a Dictionary of ID to Character and ViceVersa
char_to_idx = {ch:i for i,ch in enumerate(chars)}
# print(char_to_idx)
idx_to_char = {i:ch for i,ch in enumerate(chars)}
# print(idx_to_char)
```

    Corona Virus article has 10223 characters, 75 unique characters

# Constants and Hyperparameters

```
Hidden_Layer_size = 100 #size of the hidden layer
Time_steps = 40 # Number of time steps (length of the sequence) used for training
learning_rate = 1e-1 # Learning Rate
weight_sd = 0.1 #Standard deviation of weights for initialization
z_size = Hidden_Layer_size + X_size #Size of concatenation(H, X) vector
```

# Activation Functions and Derivatives

```
def sigmoid(x): # sigmoid function
  return 1/(1+np.exp(-x))

def dsigmoid(y): # derivative of sigmoid function
  return y*(1-y)

def tanh(x): # tanh function
  return ((np.exp(x) - np.exp(-x))/(np.exp(x)+np.exp(-x)))

def dtanh(y): # derivative of tanh
  return 1-pow(y,2)
```

# Parameters

```
# Param Class to initialize name and parameter to different internal variables
class Param:
    def __init__(self, name, value):
      self.name = name
      self.v = value # parameter value
      self.d = np.zeros_like(value) # derivative
      self.m = np.zeros_like(value) # momentum for Adagrad
```

We use random weights with normal distribution (0, weight_sd) for  tanh  activation function and (0.5, weight_sd) for  `sigmoid`  activation function.

Biases are initialized to zeros.

# LSTM
You are making this network, please note f, i, c and o (also "v") in the image below:

Please note that we are concatenating the old_hidden_vector and new_input.

```
size_a = Hidden_Layer_size #Number of Layers in one DNN
size_b = z_size # Dimensionality of Hidden Layer
size_c = X_size # Total Number of Character

class Parameters:
    def __init__(self):
        self.W_f = Param('W_f', np.random.randn(size_a, size_b) * weight_sd + 0.5)
        self.b_f = Param('b_f', np.zeros((size_a, 1)))

        self.W_i = Param('W_i', np.random.randn(size_a, size_b) * weight_sd + 0.5)
        self.b_i = Param('b_i', np.zeros((size_a, 1)))

        self.W_C = Param('W_C', np.random.randn(size_a, size_b) * weight_sd)
        self.b_C = Param('b_C', np.zeros((size_a, 1)))

        self.W_o = Param('W_o', np.random.randn(size_a, size_b) * weight_sd + 0.5)
        self.b_o = Param('b_o', np.zeros((size_a, 1)))

        #For final layer to predict the next character
        self.W_v = Param('W_v', np.random.randn(X_size, size_a) * weight_sd)
        self.b_v = Param('b_v', np.zeros((size_c, 1)))

    def all(self):
        return [self.W_f, self.W_i, self.W_C, self.W_o, self.W_v,
               self.b_f, self.b_i, self.b_C, self.b_o, self.b_v]

parameters = Parameters()
```

Look at these operations which we'll be writing:

**Concatenation of h and x:**

$z\:=\:\left[h_{t-1},\:x\right]$

$f_t=\sigma\left(W_f\cdot z\:+\:b_f\:\right)$

$i_i=\sigma\left(W_i\cdot z\:+\:b_i\right)$

$\overline{C_t}=\tanh\left(W_C\cdot z\:+\:b_C\right)$

$C_t=f_t\ast C_{t-1}+i_t\ast \overline{C}_t$

$o_t=\sigma\left(W_o\cdot z\:+\:b_i\right)$

$h_t=o_t\ast\tanh\left(C_t\right)$

**Logits:**

$v_t=W_v\cdot h_t+b_v$

**Softmax:**

$\hat{y}=softmax\left(v_t\right)$

```
# All the operations in the functions are written based on the Equations
def forward(x, h_prev, C_prev, p = parameters):
    assert x.shape == (X_size, 1)
    assert h_prev.shape == (Hidden_Layer_size, 1)
    assert C_prev.shape == (Hidden_Layer_size, 1)
    # print(p.all())

    z = np.row_stack((h_prev, x))
    # print(z)
    f = sigmoid(np.add(np.dot(p.W_f.v,z),p.b_f.v))
    # print(np.dot(p.W_f.v,z).shape)
    # print(f.shape)
    # print(p.b_f.v.shape)
    # print(p.W_f.v.shape)
    # print(z.shape)

    i = sigmoid(np.add(np.dot(p.W_i.v,z),p.b_i.v))
    C_bar = tanh(np.add(np.dot(p.W_C.v,z),p.b_C.v))

    C = np.add(f*C_prev,i*C_bar)
    o = sigmoid(np.add(np.dot(p.W_o.v,z), p.b_o.v))
    h = o*tanh(C)

    v = np.add(np.dot(p.W_v.v,h),p.b_v.v)
    y = np.exp(v) / np.sum(np.exp(v)) #softmax

    return z, f, i, C_bar, C, o, h, v, y
```

```
z, f, i, C_bar, C, o, h, v, y = forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)))
```

# Backpropagation

Here we are defining the backpropagation. It's too complicated, here is the whole code. (Please note that this would work only if your earlier code is perfect).

```
def backward(target, dh_next, dC_next, C_prev,
             z, f, i, C_bar, C, o, h, v, y,
             p = parameters):

    assert z.shape == (X_size + Hidden_Layer_size, 1)
    assert v.shape == (X_size, 1)
    assert y.shape == (X_size, 1)

    for param in [dh_next, dC_next, C_prev, f, i, C_bar, C, o, h]:
        assert param.shape == (Hidden_Layer_size, 1)

    dv = np.copy(y)
    dv[target] -= 1

    p.W_v.d += np.dot(dv, h.T)
    p.b_v.d += dv

    dh = np.dot(p.W_v.v.T, dv)
    dh += dh_next
    do = dh * tanh(C)
    do = dsigmoid(o) * do
    p.W_o.d += np.dot(do, z.T)
    p.b_o.d += do

    dC = np.copy(dC_next)
    dC += dh * o * dtanh(tanh(C))
    dC_bar = dC * i
    dC_bar = dtanh(C_bar) * dC_bar
    p.W_C.d += np.dot(dC_bar, z.T)
    p.b_C.d += dC_bar

    di = dC * C_bar
    di = dsigmoid(i) * di
    p.W_i.d += np.dot(di, z.T)
    p.b_i.d += di

    df = dC * C_prev
    df = dsigmoid(f) * df
    p.W_f.d += np.dot(df, z.T)
    p.b_f.d += df

    dz = (np.dot(p.W_f.v.T, df)
         + np.dot(p.W_i.v.T, di)
         + np.dot(p.W_C.v.T, dC_bar)
         + np.dot(p.W_o.v.T, do))
    dh_prev = dz[:Hidden_Layer_size, :]
    dC_prev = f * dC

    return dh_prev, dC_prev
```

# Forward and Backward Combined Pass

Let's first clear the gradients before each backward pass

```
# This is Used to clear the Gradients
def clear_gradients(params = parameters):
    for p in params.all():
        p.d.fill(0)
```

Clip gradients to mitigate exploding gradients

```
# It clips the values of the array is they exceeed the maximum or minimum constraints
def clip_gradients(params = parameters):
    for p in params.all():
        np.clip(p.d, -1, 1, out=p.d)
```

Calculate and store the values in forward pass. Accumulate gradients in backward pass and clip gradients to avoid exploding gradients.

input, target are list of integers, with character indexes.
h_prev is the array of initial h at  h−1  (size H x 1)
C_prev is the array of initial C at  C−1  (size H x 1)
Returns loss, final  hT  and  CT

```
def forward_backward(inputs, targets, h_prev, C_prev):
    global paramters

    # To store the values for each time step
    x_s, z_s, f_s, i_s,  = {}, {}, {}, {}
    C_bar_s, C_s, o_s, h_s = {}, {}, {}, {}
    v_s, y_s =  {}, {}

    # Values at t - 1
    h_s[-1] = np.copy(h_prev)
    C_s[-1] = np.copy(C_prev)

    loss = 0
    # Loop through time steps
    assert len(inputs) == Time_steps
    for t in range(len(inputs)):
        x_s[t] = np.zeros((X_size, 1))
        x_s[t][inputs[t]] = 1 # Input character

        (z_s[t], f_s[t], i_s[t],
        C_bar_s[t], C_s[t], o_s[t], h_s[t],
        v_s[t], y_s[t]) = \
            forward(x_s[t], h_s[t - 1], C_s[t - 1]) # Forward pass

        loss += -np.log(y_s[t][targets[t], 0]) # Loss for at t

    clear_gradients()

    dh_next = np.zeros_like(h_s[0]) #dh from the next character
    dC_next = np.zeros_like(C_s[0]) #dh from the next character

    for t in reversed(range(len(inputs))):
        # Backward pass
        dh_next, dC_next = \
            backward(target = targets[t], dh_next = dh_next,
                     dC_next = dC_next, C_prev = C_s[t-1],
                     z = z_s[t], f = f_s[t], i = i_s[t], C_bar = C_bar_s[t],
                     C = C_s[t], o = o_s[t], h = h_s[t], v = v_s[t],
                     y = y_s[t])

    clip_gradients()

    return loss, h_s[len(inputs) - 1], C_s[len(inputs) - 1]
```

# Sample the next character

```
def sample(h_prev, C_prev, first_char_idx, sentence_length):
    x = np.zeros((X_size, 1))
    x[first_char_idx] = 1

    h = h_prev
    C = C_prev

    indexes = []

    for t in range(sentence_length):
        _, _, _, _, C, _, h, _, p = forward(x, h, C)
        idx = np.random.choice(range(X_size), p=p.ravel())
        x = np.zeros((X_size, 1))
        x[idx] = 1
        indexes.append(idx)

    return indexes
```

# Training (Adagrad)

Update the graph and display a sample output

```
def update_status(inputs, h_prev, C_prev):
    #initialized later
    global plot_iter, plot_loss
    global smooth_loss

    # Get predictions for 200 letters with current model

    sample_idx = sample(h_prev, C_prev, inputs[0], 200)
    txt = ''.join(idx_to_char[idx] for idx in sample_idx)

    # Clear and plot
    plt.plot(plot_iter, plot_loss)
    display.clear_output(wait=True)
    plt.show()

    #Print prediction and loss
    print("----\n %s \n----" % (txt, ))
    print("iter %d, loss %f" % (iteration, smooth_loss))
```

# Update Parameters

\begin{align}
\theta_i &= \theta_i - \eta\frac{d\theta_i}{\sum dw_{\tau}^2} \\
d\theta_i &= \frac{\partial L}{\partial \theta_i}
\end{align}

```
def update_paramters(params = parameters):
    for p in params.all():
        p.m += p.d * p.d # Calculate sum of gradients
        #print(learning_rate * dparam)
        p.v += -(learning_rate * p.d / np.sqrt(p.m + 1e-8))
```

To delay the keyboard interrupt to prevent the training from stopping in the middle of an iteration

```
# Exponential average of loss
# Initialize to a error of a random model
smooth_loss = -np.log(1.0 / X_size) * Time_steps

iteration, pointer = 0, 0

# For the graph
plot_iter = np.zeros((0))
plot_loss = np.zeros((0))
```

# Training Loop

```
iter = 50000
while iter > 0:
  # Reset
  if pointer + Time_steps >= len(data) or iteration == 0:
      g_h_prev = np.zeros((Hidden_Layer_size, 1))
      g_C_prev = np.zeros((Hidden_Layer_size, 1))
      pointer = 0

  inputs = ([char_to_idx[ch]
              for ch in data[pointer: pointer + Time_steps]])
  targets = ([char_to_idx[ch]
              for ch in data[pointer + 1: pointer + Time_steps + 1]])

  loss, g_h_prev, g_C_prev = \
      forward_backward(inputs, targets, g_h_prev, g_C_prev)
  smooth_loss = smooth_loss * 0.999 + loss * 0.001

  # Print every hundred steps
  if iteration % 100 == 0:
      update_status(inputs, g_h_prev, g_C_prev)

  update_paramters()

  plot_iter = np.append(plot_iter, [iteration])
  plot_loss = np.append(plot_loss, [loss])

  pointer += Time_steps
  iteration += 1
  iter = iter -1
```

```
iter = 50000
while iter > 0:
  # Reset
  if pointer + Time_steps >= len(data) or iteration == 0:
      g_h_prev = np.zeros((Hidden_Layer_size, 1))
      g_C_prev = np.zeros((Hidden_Layer_size, 1))
      pointer = 0

  inputs = ([char_to_idx[ch]
              for ch in data[pointer: pointer + Time_steps]])
  targets = ([char_to_idx[ch]
              for ch in data[pointer + 1: pointer + Time_steps + 1]])

  loss, g_h_prev, g_C_prev = \
      forward_backward(inputs, targets, g_h_prev, g_C_prev)
  smooth_loss = smooth_loss * 0.999 + loss * 0.001

  # Print every hundred steps
  if iteration % 100 == 0:
      update_status(inputs, g_h_prev, g_C_prev)

  update_paramters()

  plot_iter = np.append(plot_iter, [iteration])
  plot_loss = np.append(plot_loss, [loss])

  pointer += Time_steps
  iteration += 1
  iter = iter -1
```

# Quiz Questions

## Quiz Question 1

What is the value of sigmoid(0) calculated from  your code? (Answer up to 1 decimal point, e.g. 4.2 and NOT 4.29999999, no rounding off).

```
print(sigmoid(0))
```

    0.5

## Quiz Question 2

What is the value of dsigmoid(sigmoid(0)) calculated from your code?? (Answer up to 2 decimal point, e.g. 4.29 and NOT 4.29999999, no rounding off).

```
print(dsigmoid(sigmoid(0)))
```

    0.25

## Quiz Question 3

What is the value of tanh(dsigmoid(sigmoid(0))) calculated from your code?? (Answer up to 5 decimal point, e.g. 4.29999 and NOT 4.29999999, no rounding off).

```
print(tanh(dsigmoid(sigmoid(0))))
```

    0.24491866240370908

## Quiz Question 4

What is the value of dtanh(tanh(dsigmoid(sigmoid(0)))) calculated from your code?? (Answer up to 5 decimal point, e.g. 4.29999 and NOT 4.29999999, no rounding off).

```
print(dtanh(tanh(dsigmoid(sigmoid(0)))))
```

    0.940014848806378

## Quiz Question 5

In the class definition of Parameter class, what should be size_a, size_b, and size_c? ONLY use the variables defined above.

```
size_a = Hidden_Layer_size #Number of Layers in one DNN
size_b = z_size # Dimensionality of Hidden Layer
size_c = X_size # Total Number of Character

```

## Quiz Question 6

What is the output of 'print(len(forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)), parameters)))'?

```
print(len(forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)), parameters)))
```

    9

## Quiz Question 7

Assuming you have fixed the forward function, run this command:
z, f, i, C_bar, C, o, h, v, y = forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)))

Now, find these values:

1.   print(z.shape)
2.   print(np.sum(z))
3.   print(np.sum(f))

Copy and paste exact values you get in the logs into the quiz.

```
print(z.shape)
print(np.sum(z))
print(np.sum(f))
```

    (175, 1)
    0.0
    50.0

## Quiz Question 8

Run the above code for 50000 iterations making sure that you have 100 hidden layers and time_steps is 40. What is the loss value you're seeing?

## Quiz Question 9

Run the above code for further 50000 iterations making sure that you have 100 hidden layers and time_steps is 40. What is the loss value you're seeing?

Answer: 3.31


```python
from datascience import *

import sympy
import matplotlib.pyplot as plt
import matplotlib as mpl
import matplotlib.patches as patches
plt.style.use('seaborn-muted')
mpl.rcParams['figure.dpi'] = 200
%matplotlib inline

from IPython.display import display
import numpy as np
import pandas as pd
solve = lambda x,y: sympy.solve(x-y)[0] if len(sympy.solve(x-y))==1 else "Not Single Solution"

import warnings
warnings.filterwarnings('ignore')
```

# Market Equilibria

We will now explore the relationship between price and quantity of oranges produced between 1924 and 1938. Since the data {cite}`01demand-fruits` is from the 1920s and 1930s, it is important to remember that the prices are much lower than what they would be today because of inflation, competition, innovations, and other factors. For example, in 1924, a ton of oranges would have costed \$6.63; that same amount in 2019 is \$100.78.

```python
fruitprice = Table.read_table('fruitprice.csv')
fruitprice
```

            Year	 Pear Price	 Pear Unloads (Tons)	 Plum Price	 Plum Unloads	 Peach Price	 Peach Unloads	 Orange Price	 Orange Unloads	 NY Factory Wages

            1924	 8.04      	 18489              	 8.86      	 6582        	 4.96       	 41880        	 6.63        	 21258         	 27.22

            1925	 5.67      	 21919              	 7.27      	 5526        	 4.87       	 38772        	 9.19        	 15426         	 28.03

            1926	 5.44      	 29328              	 6.68      	 5742        	 3.35       	 46516        	 7.2         	 24762         	 28.89

            1927	 7.15      	 17082              	 8.09      	 5758        	 5.7        	 32500        	 8.63        	 22766         	 29.14

            1928	 5.81      	 20708              	 7.41      	 6000        	 4.13       	 46820        	 10.71       	 18766         	 29.34

            1929	 7.6       	 13071              	 10.86     	 3504        	 6.7        	 36990        	 6.36        	 35702         	 29.97

            1930	 5.06      	 22068              	 6.23      	 7998        	 6.35       	 29680        	 10.5        	 23718         	 28.68

            1931	 5.4       	 19255              	 6.86      	 5638        	 3.91       	 50940        	 5.81        	 39263         	 26.35

            1932	 4.06      	 17293              	 6.09      	 7364        	 4.57       	 27642        	 4.71        	 38553         	 21.98

            1933	 4.78      	 11063              	 5.86      	 8136        	 3.57       	 35560        	 4.6         	 36540         	 22.26

... (5 rows omitted)

## Finding the Equilibrium

An important concept in econmics is the market equilibrium. This is the point at which the demand and supply curves meet and represents the "optimal" level of production and price in that market.

```{admonition} Definition
The **market equilibrium** ...
```

Let's walk through how to the market equilibrium using the market for oranges as an example.

### Data Preprocessing

Because we are only examining the relationship between prices and quantity for oranges, we can create a new table with the relevant columns: `Year`, `Orange Price`, and `Orange Unloads`.

```python
oranges_raw = fruitprice.select("Year", "Orange Price", "Orange Unloads")
oranges_raw
```

            Year	 Orange Price	 Orange Unloads

            1924	 6.63        	 21258

            1925	 9.19        	 15426

            1926	 7.2         	 24762

            1927	 8.63        	 22766

            1928	 10.71       	 18766

            1929	 6.36        	 35702

            1930	 10.5        	 23718

            1931	 5.81        	 39263

            1932	 4.71        	 38553

            1933	 4.6         	 36540

... (5 rows omitted)

Next, we will rename our columns. In this case, let's rename `Orange Unloads` to `Quantity` and `Orange Price` to `Price` for brevity and understandability.

```python
oranges = oranges_raw.relabel("Orange Unloads", "Quantity").relabel("Orange Price", "Price")
oranges
```

            Year	 Price	 Quantity

            1924	 6.63 	 21258

            1925	 9.19 	 15426

            1926	 7.2  	 24762

            1927	 8.63 	 22766

            1928	 10.71	 18766

            1929	 6.36 	 35702

            1930	 10.5 	 23718

            1931	 5.81 	 39263

            1932	 4.71 	 38553

            1933	 4.6  	 36540

... (5 rows omitted)

### Visualize the  Relationship

To construct the demand curve, let's first see what the relationship between price and quantity is. We would expect to see a downward-sloping line between price and quantity; if a product's price increases, consumers will purchase less, and if a product's price decreases, then consumers will purchase more.

To find this, we will create a scatterplot and draw a regression line (by setting `fit_line = True` in the `oranges.scatter` scall) between the points. Regression lines are helpful because they consolidate all the datapoints into a single line, helping us better understand the relationship between the two variables.

```python
oranges.scatter("Quantity", "Price", fit_line = True, width=7, height=7)
plt.title("Demand Curve for Oranges", fontsize = 16);
```

The visualization shows a negative relationship between quantity and price, which is exactly what we expected! As we've discussed, as the price increases, fewer consumers will purchase the oranges, so the quantity demanded will decrease. This corresponds to a leftward movement along the demand curve. Alternatively, as the price decreases, the quantity sold will increase because consumers want to maximize their purchasing power and buy more oranges; this is shown by a rightward movement along the curve.

As a quick refresher, scatterplots can show positive, negative, or neutral correlations among two variables:
- If two variables have a positive correlation, then as one variable increases, the other increases too.
- If two variables have a negative correlation, then as one variable increass, the other decreases.
- If two variables have a neutral correlation, then if one varible increases, the other variable stays constant.

Note that scatterplots do not show or prove causation between two variables-- it is up to the data scientists to prove any causation.

### Fit a Polynomial

We will now quantify our demand curve using NumPy's [`np.polyfit` functio](https://numpy.org/doc/stable/reference/generated/numpy.polyfit.html). `np.polyfit` returns an array of size 2, where the first element is the slope and the second is the $y$-intercept.

It takes 3 parameters:
- array of x-coordinates
- array of y-coordinates
- degree of polynomial

Because we are looking for a **linear** function to serve as the demand curve, we will use 1 for the degree of polynomial.

The general template for the demand curve is $y = mx + b$, where $m$ is the slope and $b$ is $y$-intercept. In economic terms, $m$ is the demand curve's slope that shows how the good's price affects the quantity demanded, and $b$ encompasses the effects of all of the exogenous non-price factors that affect demand.

```python
np.polyfit(oranges.column("Quantity"), oranges.column("Price"), 1)
```

    array([-2.14089690e-04,  1.33040264e+01])

This shows that the demand curve is $y = -0.000214x+ 13.3$. The slope is -0.000214 and $y$-intercept is 13.3. That means that as quantity increases by 1 unit (in this case, 1 ton), price decreases by 0.000214 units (in this case, \$0.000214).

### Create the Demand Curve

We will now use SymPy to write out this demand curve. To do so, we start by creating a symbol `Q` that we can use to create the equation.

```python
Q = sympy.Symbol("Q")
demand = -0.000214 * Q + 13.3
demand
```

    -0.000214*Q + 13.3

### Create the Supply Curve

As we will learn, the supply curve is the relationship between the price of a good or service and the quantity of that good or service that the seller is willing to supply. They show how much of a good suppliers are willing and able to supply at different prices. In this case, as the price of the beef increases, the quantity of beef that beef manufacturers are willing to supply increases. They capture the producer's side of market decisions and are upward-sloping.

Let's now assume that the supply curve is given by $P = 0.00023Q + 0.8$. (Note that this supply curve is not based on data.)

```python
supply = 0.00023 * Q + 0.8
supply
```

    0.00023*Q + 0.8

 This means that as the quantity of oranges produced increases by 1, the supply curve increases by 0.00023. It originally starts out with 0.8.

### Find the Quantity Equilibrium

With the supply and demand curves known, we can solve the for equilibrium.
The equilibrium is the point where the supply curve and demand curve intersect, and denotes the price and quantity of the good transacted in the market.
At this point, the quantity of the good that consumers desire to purchase is equivalent to the quantity of the good that producers supply; there is no shortage or surplus of the good at this quantity.
The equilbrium consists of 2 components: the quantity equilbrium and price equilbrium.
The quantity equilibrium is the quantity at which the supply curve and demand curve intersect.

Let's find the quantity equilibrium for this exercise. To do this, we will use the provided `solve` function. This is a custom function that leverages some SymPy magic and will be provided to you in assignments.

```python
Q_star = solve(demand, supply)
Q_star
```

    28153.1531531532

This means that the number of tons of oranges that consumers want to purchase and producers want to provide in this market is about 28,153 tons of oranges.

### Find the Price Equilibrium

Similarly, the price equilibrium is the price at which the supply curve and demand curve intersect. The price of the good that consumers desire to purchase at is equivalent to the price of the good that producers want to sell at. There is no shortage of surplus of the product at this price.

Let's find the price equilibrium.

```python
demand.subs(Q, Q_star)
supply.subs(Q, Q_star)
```

    7.27522522522523

This means that the price of oranges in tons that consumers want to purchase at and producers want to provide is about \$7.27.

### Visualize the Market Equilibrium

Now that we have our demand and supply curves and price and quantity equilibria, we can visualize them on a graph to see what they look like.

There are 2 pre-made functions we will use: `plot_equation` and `plot_intercept`.
- `plot_equation`: It takes in the equation we made previously (either demand or supply) and visualizes the equations between the different prices we give it
- `plot_intercept`: It takes in two different equations (demand and supply), finds the point at which the two intersect, and creates a scatter plot of the result

```python
def plot_equation(equation, price_start, price_end, label=None):
    plot_prices = [price_start, price_end]
    plot_quantities = [equation.subs(list(equation.free_symbols)[0], c) for c in plot_prices]
    plt.plot(plot_prices, plot_quantities, label=label)

def plot_intercept(eq1, eq2):
    ex = sympy.solve(eq1-eq2)[0]
    why = eq1.subs(list(eq1.free_symbols)[0], ex)
    plt.scatter([ex], [why], zorder=10, color="tab:orange")
    return (ex, why)
```

We can leverage these functions and the equations we made earlier to create a graph that shows the market equilibrium.

```python
mpl.rcParams['figure.dpi'] = 150
plot_equation(demand, 5000, 50000, label = "Demand")
plot_equation(supply, 5000, 50000, label = "Supply")
plt.ylim(0,13)
plt.title("Orange Supply and Demand in 1920's and 1930's", fontsize = 20)
plt.xlabel("Quantity (Tons)", fontsize = 14)
plt.ylabel("Price ($)", fontsize = 14)
plot_intercept(supply, demand)
plt.legend(loc = "upper right", fontsize = 12)
plt.show()
```

You can also practice on your own and download additional data sets [here](http://users.stat.ufl.edu/~winner/datasets.html), courtesy of the University of Flordia's Statistics Department.


```python
import numpy as np
import sympy as sym
import matplotlib.pyplot as plt
%matplotlib inline
import control
```

# Criterio de estabilidad de Routh

Hasta el momento se ha mostrado que se puede identificar si un sistema LTI es estable o no al encontrar los polos del sistema, pues si TODOS los polos del sistema tienen  parte real negativa, el sistema es estable. Además, se ha evidenciado que al realimentar negativamente un sistema, los polos del sistema se desplazan a nuevas ubicaciones.

El [Criterio de estabilidad de Routh-Hurwitz](https://en.wikipedia.org/wiki/Routh%E2%80%93Hurwitz_stability_criterion) es una forma de determinar la estabilidad del sistema sin tener que calcular sus raíces. Además, al evitar el cálculo de las raíces, se evaden posibles errores al trabajar con polinomios de orden elevado.

**Ejemplo**

Suponga un proceso modelado por:

$$G_p(s) = \frac{1}{\left (s + 1\right )^{10}}$$

Las raíces del sistema son:

$$s_{1,2,3...,10} = -1$$

```python
# Se define la función de transferencia del proceso
Gp = control.tf(1, [1,1])
Gp10 = Gp*Gp*Gp*Gp*Gp*Gp*Gp*Gp*Gp*Gp
Gp10
```

$$\frac{1}{s^{10} + 10 s^9 + 45 s^8 + 120 s^7 + 210 s^6 + 252 s^5 + 210 s^4 + 120 s^3 + 45 s^2 + 10 s + 1}$$

```python
Gp10.pole()
```

    array([-1.05835839+0.j        , -1.04656789+0.03476893j,
           -1.04656789-0.03476893j, -1.01659732+0.05502978j,
           -1.01659732-0.05502978j, -0.98133256+0.05352511j,
           -0.98133256-0.05352511j, -0.95422344+0.0323362j ,
           -0.95422344-0.0323362j , -0.9441992 +0.j        ])

```python
ceros = Gp10.zero()
ceros
```

    array([], dtype=float64)

```python
polos = (Gp*Gp*Gp*Gp).pole()
polos
```

    array([-1.00026013+0.j        , -0.99999997+0.00026011j,
           -0.99999997-0.00026011j, -0.99973992+0.j        ])

Observe que los polos no están ubicados en $s=-1$. Estos errores se deben a las representaciones discretas de los números.

La función `routh` permite encontrar la matriz de Routh a partir de un objeto tipo `Poly` de `Sympy`

```python
!pip install tbcontrol
from tbcontrol.symbolic import routh
help(routh)
```

    Requirement already satisfied: tbcontrol in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (0.2.1)
    Requirement already satisfied: numpy in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (1.21.5)
    Requirement already satisfied: scipy in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (1.3.0)
    Requirement already satisfied: tqdm in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (4.62.3)
    Requirement already satisfied: packaging in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tbcontrol) (21.0)
    Requirement already satisfied: pyparsing>=2.0.2 in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from packaging->tbcontrol) (2.4.7)
    Requirement already satisfied: colorama in c:\users\usuario\.conda\envs\sistdin\lib\site-packages (from tqdm->tbcontrol) (0.4.4)
    Help on function routh in module tbcontrol.symbolic:

    routh(p)
        Construct the Routh-Hurwitz array given a polynomial in s

        Input: p - a sympy.Poly object
        Output: The Routh-Hurwitz array as a sympy.Matrix object

```python
s = sym.Symbol('s')

a0, a1, a2, a3, a4,a5,a6,a7,a8,a9,a10 = sym.symbols('a_0:11')
p = (a0 + a1*s**1 + a2*s**2 + a3*s**3 + a4*s**4 + a5*s**5 +
     a6*s**6)
p = sym.Poly(p, s)
p
```

$\displaystyle \operatorname{Poly}{\left( a_{6} s^{6} + a_{5} s^{5} + a_{4} s^{4} + a_{3} s^{3} + a_{2} s^{2} + a_{1} s + a_{0}, s, domain=\mathbb{Z}\left[a_{0}, a_{1}, a_{2}, a_{3}, a_{4}, a_{5}, a_{6}\right] \right)}$

```python
routh(p)
```

$\displaystyle \left[\begin{matrix}a_{6} & a_{4} & a_{2} & a_{0}\\a_{5} & a_{3} & a_{1} & 0\\- \frac{a_{3} a_{6}}{a_{5}} + a_{4} & - \frac{a_{1} a_{6}}{a_{5}} + a_{2} & a_{0} & 0\\\frac{a_{3} \left(a_{3} a_{6} - a_{4} a_{5}\right) + a_{5} \left(- a_{1} a_{6} + a_{2} a_{5}\right)}{a_{3} a_{6} - a_{4} a_{5}} & \frac{a_{0} a_{5}^{2} + a_{1} \left(a_{3} a_{6} - a_{4} a_{5}\right)}{a_{3} a_{6} - a_{4} a_{5}} & 0 & 0\\\frac{\left(a_{0} a_{5}^{2} + a_{1} \left(a_{3} a_{6} - a_{4} a_{5}\right)\right) \left(a_{3} a_{6} - a_{4} a_{5}\right) - \left(a_{1} a_{6} - a_{2} a_{5}\right) \left(a_{3} \left(a_{3} a_{6} - a_{4} a_{5}\right) - a_{5} \left(a_{1} a_{6} - a_{2} a_{5}\right)\right)}{a_{5} \left(a_{3} \left(a_{3} a_{6} - a_{4} a_{5}\right) - a_{5} \left(a_{1} a_{6} - a_{2} a_{5}\right)\right)} & a_{0} & 0 & 0\\\frac{- a_{0} a_{5} \left(a_{3} \left(a_{3} a_{6} - a_{4} a_{5}\right) - a_{5} \left(a_{1} a_{6} - a_{2} a_{5}\right)\right)^{2} + \left(a_{0} a_{5}^{2} + a_{1} \left(a_{3} a_{6} - a_{4} a_{5}\right)\right) \left(\left(a_{0} a_{5}^{2} + a_{1} \left(a_{3} a_{6} - a_{4} a_{5}\right)\right) \left(a_{3} a_{6} - a_{4} a_{5}\right) - \left(a_{1} a_{6} - a_{2} a_{5}\right) \left(a_{3} \left(a_{3} a_{6} - a_{4} a_{5}\right) - a_{5} \left(a_{1} a_{6} - a_{2} a_{5}\right)\right)\right)}{\left(a_{3} a_{6} - a_{4} a_{5}\right) \left(\left(a_{0} a_{5}^{2} + a_{1} \left(a_{3} a_{6} - a_{4} a_{5}\right)\right) \left(a_{3} a_{6} - a_{4} a_{5}\right) - \left(a_{1} a_{6} - a_{2} a_{5}\right) \left(a_{3} \left(a_{3} a_{6} - a_{4} a_{5}\right) - a_{5} \left(a_{1} a_{6} - a_{2} a_{5}\right)\right)\right)} & 0 & 0 & 0\\a_{0} & 0 & 0 & 0\end{matrix}\right]$

El sistema es estable siempre que los elementos de la primera columna sean del mismo signo.

----------------------------------

¿Qué pasa con el sistema de décimo orden definido anteriormente?

```python
Gp10Den
```

```python
Coef_den = Gp10.den[0][0].tolist()
orden_den = len(Coef_den)-1
Gp10Den = 0
for val in Coef_den:
    Gp10Den = Gp10Den + val*s**(orden_den)
    orden_den = orden_den-1
Gp10Den = sym.Poly(Gp10Den, s)
routh(Gp10Den)
```

$\displaystyle \left[\begin{matrix}1 & 45 & 210 & 210 & 45 & 1\\10 & 120 & 252 & 120 & 10 & 0\\33 & \frac{924}{5} & 198 & 44 & 1 & 0\\64 & 192 & \frac{320}{3} & \frac{320}{33} & 0 & 0\\\frac{429}{5} & 143 & 39 & 1 & 0 & 0\\\frac{256}{3} & \frac{2560}{33} & \frac{1280}{143} & 0 & 0 & 0\\65 & 30 & 1 & 0 & 0 & 0\\\frac{16384}{429} & \frac{16384}{2145} & 0 & 0 & 0 & 0\\17 & 1 & 0 & 0 & 0 & 0\\\frac{65536}{12155} & 0 & 0 & 0 & 0 & 0\\1 & 0 & 0 & 0 & 0 & 0\end{matrix}\right]$

```python
Gp10Den
```

$\displaystyle \operatorname{Poly}{\left( s^{10} + 10 s^{9} + 45 s^{8} + 120 s^{7} + 210 s^{6} + 252 s^{5} + 210 s^{4} + 120 s^{3} + 45 s^{2} + 10 s + 1, s, domain=\mathbb{Z} \right)}$

Todos los elementos de la primera columna son positivos, por lo cuál el sistema es estable.

¿y si se realimenta y se configura un controlador $G_c(s) = k_c$?

```python
kc = sym.symbols('k_c', real=True,positive = True)

##########################################
Coef_num = Gp10.num[0][0].tolist()
orden_num = len(Coef_num)-1
Gp10Num = 0
for val in Coef_num:
    Gp10Num = Gp10Num + val*s**(orden_num)
    orden_num = orden_num-1
Gp10Num = sym.Poly(Gp10Num, s)
##########################################
Coef_den = Gp10.den[0][0].tolist()
orden_den = len(Coef_den)-1
Gp10Den = 0
for val in Coef_den:
    Gp10Den = Gp10Den + val*s**(orden_den)
    orden_den = orden_den-1
Gp10Den = sym.Poly(Gp10Den, s)
#########################################3
### DenominadorLC = Nc*Np + Dc*Dp
A = routh(kc*Gp10Num + Gp10Den)
A
```

$\displaystyle \left[\begin{matrix}1 & 45 & 210 & 210 & 45 & k_{c} + 1\\10 & 120 & 252 & 120 & 10 & 0\\33 & \frac{924}{5} & 198 & 44 & k_{c} + 1 & 0\\64 & 192 & \frac{320}{3} & \frac{320}{33} - \frac{10 k_{c}}{33} & 0 & 0\\\frac{429}{5} & 143 & \frac{5 k_{c}}{32} + 39 & k_{c} + 1 & 0 & 0\\\frac{256}{3} & \frac{2560}{33} - \frac{50 k_{c}}{429} & \frac{1280}{143} - \frac{150 k_{c}}{143} & 0 & 0 & 0\\\frac{15 k_{c}}{128} + 65 & \frac{155 k_{c}}{128} + 30 & k_{c} + 1 & 0 & 0 & 0\\- \frac{150 k_{c}^{2} + 1118208 k_{c} - 27262976}{1287 k_{c} + 713856} & - \frac{6750 k_{c}^{2} + 8372224 k_{c} - 27262976}{6435 k_{c} + 3569280} & 0 & 0 & 0 & 0\\\frac{375 k_{c}^{3} + 17193696 k_{c}^{2} - 1722679296 k_{c} - 7415529472}{32 \left(75 k_{c}^{2} + 559104 k_{c} - 13631488\right)} & k_{c} + 1 & 0 & 0 & 0 & 0\\- \frac{6250 k_{c}^{4} + 785920000 k_{c}^{3} - 154662862848 k_{c}^{2} - 1078036791296 k_{c} + 2199023255552}{20625 k_{c}^{3} + 945653280 k_{c}^{2} - 94747361280 k_{c} - 407854120960} & 0 & 0 & 0 & 0 & 0\\k_{c} + 1 & 0 & 0 & 0 & 0 & 0\end{matrix}\right]$

```python
resp = sym.solve([e > 0 for e in A[:, 0]], kc)
```

Esto indica que $k_c<1.652$ permite obtener un sistema estable en lazo cerrado.

```python
k1 = 1.6
k2 = 1.7

Gp10LC1 = control.feedback(k1*Gp10,1)
Gp10LC2 = control.feedback(k2*Gp10,1)

```

```python
routh(k1*Gp10Num + Gp10Den)
```

$\displaystyle \left[\begin{matrix}1.0 & 45.0 & 210.0 & 210.0 & 45.0 & 2.6\\10.0 & 120.0 & 252.0 & 120.0 & 10.0 & 0\\33.0 & 184.8 & 198.0 & 44.0 & 2.6 & 0\\64.0 & 192.0 & 106.666666666667 & 9.21212121212121 & 0 & 0\\85.8 & 143.0 & 39.25 & 2.6 & 0 & 0\\85.3333333333333 & 77.3892773892774 & 7.27272727272727 & 0 & 0 & 0\\65.1875 & 31.9375 & 2.6 & 0 & 0 & 0\\35.5816711252953 & 3.86921177256748 & 0 & 0 & 0 & 0\\24.8488997615212 & 2.6 & 0 & 0 & 0 & 0\\0.146216154506988 & 0 & 0 & 0 & 0 & 0\\2.6 & 0 & 0 & 0 & 0 & 0\end{matrix}\right]$

```python
routh(k2*Gp10Num + Gp10Den)
```

$\displaystyle \left[\begin{matrix}1.0 & 45.0 & 210.0 & 210.0 & 45.0 & 2.7\\10.0 & 120.0 & 252.0 & 120.0 & 10.0 & 0\\33.0 & 184.8 & 198.0 & 44.0 & 2.7 & 0\\64.0 & 192.0 & 106.666666666667 & 9.18181818181818 & 0 & 0\\85.8 & 143.0 & 39.265625 & 2.7 & 0 & 0\\85.3333333333333 & 77.3776223776224 & 7.16783216783217 & 0 & 0 & 0\\65.19921875 & 32.05859375 & 2.7 & 0 & 0 & 0\\35.4190419051122 & 3.63404749345675 & 0 & 0 & 0 & 0\\25.3690548825024 & 2.7 & 0 & 0 & 0 & 0\\-0.135561330629251 & 0 & 0 & 0 & 0 & 0\\2.7 & 0 & 0 & 0 & 0 & 0\end{matrix}\right]$

```python
control.pzmap(Gp10LC1)
```

```python
control.pzmap(Gp10LC2)
```

Considere un proceso modelado por:

$$G_p(s) = \frac{s+3}{\left (s+2 \right )\left (s+1 \right )} $$

y un controlador $G_c(s)=k_c$

Encuentre qué valores de $k_c$ permiten que el sistema en lazo cerrado sea estable.

```python

```