Escuela de Ciencias Básicas, Tecnología e Ingeniería

ECBTI

¿Usted sabe por qué debe mantener la distancia social recomendada de personas con síntomas de la COVID-19? !Venga le cuento!

Equipo de Trabajo en Ciencia de Datos - UNAD

Expositores: Carlos Alvarez - Jeinny Peralta - Tatiana Sánchez

Abril 21 de 2020

# Modelo matemático y computacional para predecir el comportamiento de la Covid-19

## Distanciamiento social como estrategia para ralentizar la propagación

***Nota:*** basado en los artículos publicados en las páginas web:

- [Social Distancing to Slow the Coronavirus -  Modeling the flattening of the COVID-19 peaks](https://towardsdatascience.com/social-distancing-to-slow-the-coronavirus-768292f04296 "Towardsdatascience"): By Christian Hubbs, in Towardsdatascience.com, Mar 12, 2020

- [Why outbreaks like coronavirus spread exponentially, and how to “flatten the curve”](https://www.washingtonpost.com/graphics/2020/world/corona-simulator/): By Harry Stevens, in The Washington Post, Mar 14, 2020

- [Infectious Disease Modelling: Understanding the models that are used to model Coronavirus](https://towardsdatascience.com/infectious-disease-modelling-part-i-understanding-sir-28d60e29fdfc): By Henri Froese, in Towardsdatascience.com, Apr 06,2020

- [Infectious Disease Modelling: Beyond the Basic SIR Model](https://towardsdatascience.com/infectious-disease-modelling-part-i-understanding-sir-28d60e29fdfc): By Henri Froese, in Towardsdatascience.com, Apr 11,2020

# Contextualización

Como es de ámplio conocimiento de todos hoy en día, la Covid-19 ha crecido rápidamente en todo el mundo. La gran mayoría de paises han tomado medidas drásticas frente a la movilidad de sus ciudadanos. Estas medidas se toman con la intención de retrasar la propagación (no detenerla) de la enfermedad. Este tipo de estrategia se denomina *Distanciamiento Social*.

La idea detrás de esta política de salud pública es reducir el contacto *persona-a-persona* para que sea menos probable la propagación de la enfermedad y la capacidad de los sistemas de salud local no se saturen, ayudando a garantizar la adecuada atención a la población enferma y disminyendo la cantidad de decesos. Los efectos de una política en este sentido se pueden observar en la siguiente gráfica.

# Distanciamiento social - Definición

“Conjunto de medidas no farmacéuticas de control de infecciones, con el objetivo de detener o desacelerar la propagación de una enfermedad contagiosa.”

# Distanciamiento social - Definición

***Levítico, 13:46:***

“Todo el tiempo que la llaga estuviere en él, será inmundo; estará impuro, y habitará solo; fuera del campamento será su morada.”

Se han establecido colonias de leprosos y lazaretos como medios para impedir la transmisión de la lepra y otras enfermedades infecciosas.

***Lazareto - Ancona, Italia, 1700's***

***Agua de Dios, Colombia, 1870's***

# Modelos matemáticos

Los modelos matemáticos son aproximaciones, bien sustentadas y justificadas, de la realidad y en este contexto intentan brindar elementos a la toma de decisiones de políticas públicas para intentan aplanar la curva de propagación de la enfermedad.

# Simulación física de dispersión de gotículas

Veamos primer una serie de simulaciones computacionales de la dispersión de partículas producto de la tos humana empleando técnicas de la [CFD](https://en.wikipedia.org/wiki/Computational_fluid_dynamics "CFD") (Dinámica de Fluidos Computaconal) y empleando un software comercial

```python
from IPython.display import IFrame

URL = 'https://www.ansys.com/about-ansys/covid-19-simulation-insights'
IFrame(src = URL, width = 980, height = 500)
```

```python
URL = 'https://www.washingtonpost.com/graphics/2020/world/corona-simulator-spanish/'
IFrame(src = URL, width = 980, height = 600)
```

# Modelo S.I.R

Es una representación simplificada de la propagación entre miembros de una población de enfermedades contagiosas.

El modelo SIR es un modelo comportamental, es decir separa una población en diferentes grupos de acuerdo a sus comportamientos:

- **S**usceptible (que puede ser futuramente infectado, "sano")

- **I**nfectado

- **R**ecuperado (ha sido infectado y no volverá a infectarse)

Para definir las variables involucradas en este simple modelo veamos el siguiente ejemplo.

## Ejemplo

Consideremos una población de $1000$ habitantes ($N=1000$), además, consideremos que han pasado $7$ días después del brote de la epidemia. Para este instante, se sabe que $400$ de ellas están infectadas y $50$ se han recuperado, lo anterior puede ser denotado como:

*   $S(7) = 550$ (considerando que toda la población es susceptible al inicio de la pandemia).
*   $I(7) = 400$.
*   $R(7) = 50$.

Para esta enfermedad consideremos que la probabilidad de que una persona infectada contagie a una persona sana es del $20\%$, y supongamos que el número promedio de personas con las que una persona está en contacto por día es de $5$. Entonces, por día, un individuo infectado se encuentra con $5$ personas resultando infectadas el $20\%$ de ellas, es decir, la persona enferma infectará a $1$ persona por día. Esto es denotado con el parámetro $\beta$, que es la cantidad esperada de personas que una persona infectada contagia por día.

Sea $D$ la cantidad de días que una persona puede transmitir la enfermedad, este dato es extremadamente importante. Si $D = 5$, una persona infectada camina durante cinco días propagando la enfermedad e infecta a $1$ persona por día ($\beta = 1$). Entonces, esperamos que una persona infectada infecte a $5$ personas más. Este es el número de reproducción básico, $R_0$, que representa el número total de personas que infecta una persona infectada, es decir, $R_0=\beta \times D$.

Si se piensa en $D$ como el número de días que una persona infectada tiene la enfermedad, entonces se puede pensar en otro parámetro $\gamma$, como la tasa de recuperación, o la proporción de infectados que se recuperan por día. Por ejemplo, si se tienen actualmente $400$ personas infectadas y $D=5$, entonces, por día $1/5$ de ellas se recuperarán (aproximadamente $80$ personas), es decir, $\gamma=1/5$. Siendo $\gamma=1/D$, entonces $D=1/\gamma$ y $R_0=\beta \times D$, se deduce que $R_0=\beta / \gamma$.

Resumiendo, las variables involucradas en el ejemplo anterior son:

- $N$: Total de la población

- $S(t)$: Número de personas susceptibles de ser infectadas en un día $t$

- $I(t)$: Número de personas infectadas en un día $t$

- $R(t)$: Número de personas recuperadas en un día $t$

- $\beta$: Cantidad esperada de personas que una persona infectada infecta por día

- $D$: Número de días que una persona infectada tiene y puede transmitir la enfermedad

- $\gamma$: Proporción de infectados recuperados por día ($\gamma = 1/D$)

- $R_0$: Número total de personas infectadas por una persona infectada ($R_0=\beta / \gamma$)

# Derivación de las ecuaciones

Deseamos determinar el número de *infectados*, *susceptibles* y *recuperados* para todos los días, dados únicamente los parámetros $\gamma$, $\beta$ y $N$. Es difícil obtener una fórmula directa para $S(t)$, $I(t)$ y $R(t)$, sin embargo, es muy simple describir el cambio por día de $S$, $I$ y $R$, es decir, como cambia el número de *susceptibles* / *infectados* / *recuperados* dependiendo de los actuales valores.

Ahora estamos en el día $t$ después del brote de la enfermedad. Aún así, la cantidad esperada de personas que una persona infectada infecta por día es $1$ ($\beta = 1$) y el número de días que una persona infectada tiene y puede transmitir la enfermedad es $5$ ($\gamma =1/5$ y $D=5$).

En el día $t=7$, tenemos los datos dados anteriormente,  ¿cómo cambian $S(t)$, $I(t)$ y $R(t)$ al día siguiente?

Se tienen $400$ personas infectadas. Cada uno de ellos infecta a $\beta = 1$ persona por día. Sin embargo, solo $550/1000 = 55\%$ de las personas todavía son susceptibles y pueden infectarse (eso es $S(t) / N$). Entonces, infectan a $400 \times 1 \times 55/1000 = 22$.  Entonces, $22$ personas de los susceptibles se infectan, por lo que $S(t)$ cambia en menos $22$. Al conectar las variables, acabamos de derivar la primera fórmula:

$$\text{cambio de } S(t) \text{ al día siguiente } = -\beta \cdot I(t) \cdot S(t) / N$$

Lo anterior es la expresión de la deriva, $S'(t) = dS/dt$, y reescribiendo

$$S'(t) = dS(t)/dt = -\beta \cdot I(t) \cdot S(t) / N$$

Ahora, ¿cómo cambia la cantidad de infectados? hay algunas personas nuevas infectadas. Exactamente la cantidad de personas que "*salen*" de $S(t)$ "*llegan*" a $I(t)$. Entonces, tenemos $22$ nuevos infectados y ya sabemos que la fórmula será similar a esta: $I'(t)= + \beta \cdot I(t) \cdot S(t) / N$ (se gana la cantidad exacta que $S(t)$ pierde, por lo que simplementese cambia el signo). Solo falta una cosa: algunas personas se recuperan. Recuerde, tenemos $\gamma$ para eso, es la proporción de infectados que se recuperan por día, ¡eso es justo lo que necesitamos!

Tenemos $400$ infectados y $\gamma = 1/5$, por lo que un quinto de los $400$ se recupera. Eso es $1/5 \cdot 400 = 80$. Finalmente, obtenemos la fórmula:

$$I'(t) = dI(t)/dt = \beta \cdot I(t) \cdot S(t) / N - \gamma \cdot I(t)$$

El primer término del lado derecho de la ecuación es el recién infectado de los susceptibles. La segunda parte son las recuperaciones.

Finalmente, llegamos a la última fórmula, el cambio en las recuperaciones. Los recién recuperados son exactamente los $80$ que acabamos de calcular; no hay personas saliendo del compartimento "*recuperado*". Se asume que una vez recuperados, permanecen inmunes:

$$R'(t) = dR(t)/dt = \gamma \cdot I(t)$$

Ya contamos con las fórmulas que se buscaban:

$$
\begin{align}
\frac{dS}{dt} & = -\beta \cdot I \cdot \frac{S}{N} \\
\frac{dI}{dt} & = \beta \cdot I \cdot \frac{S}{N} - \gamma \cdot I \\
\frac{dR}{dt} & = \gamma \cdot I
\end{align}
$$

Este es un conjunto de Ecuaciones Diferenciales Ordinarias (ODEs).

Ahora podemos describir el cambio en el número de personas *susceptibles*, *infectadas* y *recuperadas*. A partir de estas fórmulas, afortunadamente, podemos calcular los números que realmente nos interesan: $S(t)$, $I(t)$ y $R(t)$, el número de personas *susceptibles*, *infectadas* y *recuperadas* por cada día $t$.

# Codificando el modelo

Ahora vamos a codificar las ecuaciones. Se emplearán los códigos implementados en [Infectious Disease Modelling: Understanding the models that are used to model Coronavirus](https://towardsdatascience.com/infectious-disease-modelling-part-i-understanding-sir-28d60e29fdfc): By Henri Froese, in Towardsdatascience.com, Apr 06, 2020. El código completo lo pueden descargar de [su sitio en GitHub](https://github.com/hf2000510/infectious_disease_modelling).

Inicialmente se definiran los parámetros $N$, $\beta$, $D$, $\gamma$, y las condiciones iniciales, en $t=0$, para $S(t)$, $I(t)$ y  $R_0(t)$.

```python
import numpy as np
from scipy.integrate import odeint
import matplotlib.pyplot as plt
```

Ahora se implementarán las fórmulas determinadas

```python
def deriv(y, t, N, beta, gamma):
    S, I, R = y
    dSdt = -beta * S * I / N
    dIdt = beta * S * I / N - gamma * I
    dRdt = gamma * I
    return dSdt, dIdt, dRdt
```

```python
def plotsir(t, S, I, R):
    f, ax = plt.subplots(1,1,figsize=(10,4))
    ax.plot(t, S, 'b', alpha=0.7, linewidth=2, label='Susceptible')
    ax.plot(t, I, 'y', alpha=0.7, linewidth=2, label='Infected')
    ax.plot(t, R, 'g', alpha=0.7, linewidth=2, label='Recovered')

    ax.set_xlabel('Time (days)')

    ax.yaxis.set_tick_params(length=0)
    ax.xaxis.set_tick_params(length=0)
    ax.grid(b=True, which='major', c='w', lw=2, ls='-')
    legend = ax.legend()
    legend.get_frame().set_alpha(0.5)
    for spine in ('top', 'right', 'bottom', 'left'):
        ax.spines[spine].set_visible(False)
    plt.show();

```

```python
N = 1000
beta = 1.0  # infected person infects 1 other person per day
D = 5.0 # infections lasts four days
gamma = 1.0 / D

S0, I0, R0 = 999, 1, 0  # initial conditions: one infected, rest susceptible
```

Ahora aquí es donde ocurre la magia: obtenemos nuestros valores $S(t)$, $I(t)$ y $R(t)$ de la función `odeint` de `Python` que toma las fórmulas que definimos anteriormente, las condiciones iniciales y nuestras variables $N$, $\beta$ y $\gamma$, y calcula $S$, $I$ y $R$ para cada uno de los siguientes $50$ días.

```python
t = np.linspace(0, 50, 50) # Grid of time points (in days)
y0 = S0, I0, R0 # Initial conditions vector

# Integrate the SIR equations over the time grid, t.
ret = odeint(deriv, y0, t, args=(N, beta, gamma))
S, I, R = ret.T
```

```python
plotsir(t, S, I, R)
```

Como puede ver, solo toma poco más de  $10$ días para que casi una población completa de $1000$ personas se infecte. Por supuesto, la enfermedad modelada aquí tiene un valor $R_0$ muy alto de $5.0$. Simplemente cambiando el número de personas que una persona infectada infecta por día $\beta$ a $0.5$ resulta en un escenario completamente diferente:

```python
N = 1000
beta = 0.50  # infected person infects 1 other person per day
D = 5.0 # infections lasts four days
gamma = 1.0 / D

S0, I0, R0 = 999, 1, 0  # initial conditions: one infected, rest susceptible
```

```python
t = np.linspace(0, 50, 50) # Grid of time points (in days)
y0 = S0, I0, R0 # Initial conditions vector

# Integrate the SIR equations over the time grid, t.
ret = odeint(deriv, y0, t, args=(N, beta, gamma))
S, I, R = ret.T
```

```python
plotsir(t, S, I, R)
```

Como puede ver, estos sistemas de EDO son extremadamente sensibles a los parámetros iniciales. Esa es también la razón por la cual es tan difícil modelar correctamente un brote emergente de una nueva enfermedad: simplemente no sabemos cuáles son los parámetros, e incluso los cambios leves producen resultados muy diferentes.

# Conclusiones

El modelo S.I.R. modela simplificadamente la manera como se puede transmitir una enfermedad contagiosa en un grupo de personas, sin embargo, como en todo modelo, muchas son las inquietudes que surgen. Todo dependerá de los parámetros que se tienen en cuenta y la calidad de la información, datos, con que se cuente.

```python
URL = 'https://www.geogebra.org/classic/q2avpqqy'
IFrame(src = URL, width = 980, height = 500)
```

```python
URL = 'https://www.geogebra.org/classic/mxhj5ryw'
IFrame(src = URL, width = 980, height = 500)
```

```python
URL = 'https://www.geogebra.org/classic/yasmhuqf'
IFrame(src = URL, width = 980, height = 500)
```

# *S.E.I.R*

En la literatura existen otros modelos más completos, y complejos, como el SEIR, que es una extensión del S.I.R. y que considera, además de los parámetros vistos, otros como:

- Un estado "***Deceso***" que contempla las personas que fallecen debido a la enfermedad.

- Un estado "***Expuesto***" para individuos que han contraído la enfermedad pero que aún no son infecciosos (este es conocido como el modelo ***S.E.I.R.***)

- Valores de $R_0$ dependientes del tiempo que nos permitirán modelar cuarentenas, bloqueos, ...

- Tasas de mortalidad dependientes de los recursos y la edad que nos permitirán modelar hospitales superpoblados, poblaciones con muchos jóvenes, ...

entre muchos otros posibles parámetros adicionales, que podrían complementar el modelo, pero lo harían mucho más complejo.

Al derivar las ecuaciones, ya las consideramos intuitivamente como "*instrucciones*" que nos dicen lo que le sucede a la población al día siguiente (por ejemplo, cuando $10$ personas están infectadas y la recuperación se produce a un ritmo de $\gamma = 1/5$, entonces el número de individuos recuperados al día siguiente debería aumentar en $1/5 \times 10 = 2$). Ahora solidificamos esta comprensión de las ecuaciones como "*direcciones*" o "*transiciones*" de un compartimento $S$, $I$ o $R$ a otro; esto simplificará enormemente las cosas cuando introduzcamos más compartimentos más adelante y las ecuaciones se vuelvan confusas.

## Definiciones

Los *compartimientos* son cajas, que denotan "*estados*", como este:

Las *transiciones* de un compartimento a otro se representan mediante flechas, con el siguiente etiquetado:

La *tasa* describe cuánto tiempo dura la transición, la *población* es el grupo de individuos a los que se aplica esta transición, y la *probabilidad* es la probabilidad de que la transición tenga lugar para un individuo.

### Ejemplo

Supongamos la transición de *Susceptibles* a *Infectados* en las ecuaciones $S.I.R.$, con $\beta=2$, una población total de $100$, $10$ infectados y $90$ susceptibles. La tasa es $1$, ya que las infecciones ocurren de inmediato; la población a la que se aplica la transición es $2 \times 10 = 20$ individuos, ya que los $10$ infectados infectan a $2$ personas; la probabilidad es del $90\%$, ya que $90/100$ personas aún pueden estar infectadas. Corresponde a esta notación intuitiva:

De forma más general, para todo el modelo (para $I \rightarrow R$, la *tasa* es $\gamma$ y la probabilidad es $1$ a medida que todos se recuperarán)

Como puede verse, las flechas que apuntan hacia un compartimento se agregan en la ecuación; las flechas que apuntan lejos de un compartimento se restan. Es una forma gráfica de presentar las ecuaciones determinadas arriba.

$$
\begin{align}
\frac{dS}{dt} & = -\beta \cdot I \cdot \frac{S}{N} \\
\frac{dI}{dt} & = \beta \cdot I \cdot \frac{S}{N} - \gamma \cdot I \\
\frac{dR}{dt} & = \gamma \cdot I
\end{align}
$$

Aunque ya tengamos un mejor entendimiento del modelo $S.I.R.$, e incluso se haya codificado en un lenguaje de programación como `python`, los resultados obtenidos no representan muy bien la realidad, y por ahora no deja de ser un simple "juguete" interesante.

Vamos a tratar de "mejorar" este modelo básico incluyendo otros "compartimientos"

# Introduciendo nuevos Compartimientos

## Obteniendo el compartimiento de *Expuestos*

Muchas enfermedades infecciosas tienen un período de incubación antes de ser infecciosas durante el cual el huésped aún no puede transmitir la enfermedad. Llamaremos a tales individuos, y a todo el compartimento, *expuestos*.

Intuitivamente, tendremos transiciones de la forma $S \rightarrow E \rightarrow I \rightarrow R$: las personas susceptibles pueden contraer el virus y así quedar expuestos, luego infectados y luego recuperados. La nueva transición $S \rightarrow E$ tendrá la misma flecha que la transición $S \rightarrow I$ actual, ya que la probabilidad es la misma (todos los *susceptibles* pueden estar *expuestos*), la tasa es la misma ("*exposición*" ocurre inmediatamente) y la población es igual (los individuos infecciosos pueden propagar la enfermedad y cada uno expone a $\beta$ nuevos individuos por día). Tampoco hay razón para que la transición de $I$ a $R$ cambie. La única nueva transición es la de $E$ a $I$: la probabilidad es $1$ (todos los que están expuestos se infectan), la población es $E$ (todos los expuestos se infectarán) y la tasa obtiene una nueva variable, $\delta$(delta). Llegamos a estas transiciones:

De estas transiciones, podemos derivar inmediatamente las nuevas ecuaciones:

$$
\begin{align}
\frac{dS}{dt} & = -\beta \cdot I \cdot \frac{S}{N} \\
\frac{dE}{dt} & = \beta \cdot I \cdot \frac{S}{N} - \delta \cdot E\\
\frac{dI}{dt} & = \delta \cdot E - \gamma \cdot I \\
\frac{dR}{dt} & = \gamma \cdot I
\end{align}
$$

## Programando el compartimiento de *Expuestos*

Retomando el código realizado antes y cambiando algunas líneas para adicionarle los nuevos compartimientos. Como ejemplo se modelará una enfermedad altamente infecciosa, $R_0=5.0$ en una población de $1$ millón, con un periodo de incubación de $5$ días y una recuperación de $7$ días.

```python
def plotsir(t, S, E, I, R):
    f, ax = plt.subplots(1,1,figsize=(10,4))
    ax.plot(t, S, 'b', alpha=0.7, linewidth=2, label='Susceptible')
    ax.plot(t, E, 'y', alpha=0.7, linewidth=2, label='Expuesto')
    ax.plot(t, I, 'r', alpha=0.7, linewidth=2, label='Infectado')
    ax.plot(t, R, 'g', alpha=0.7, linewidth=2, label='Recuperado')

    ax.set_xlabel('Tiempo (dias)')
    ax.set_ylabel('Población (habitantes)')

    ax.yaxis.set_tick_params(length=0)
    ax.xaxis.set_tick_params(length=0)
    ax.grid(b=True, which='major', c='w', lw=2, ls='-')
    legend = ax.legend()
    legend.get_frame().set_alpha(0.5)
    for spine in ('top', 'right', 'bottom', 'left'):
        ax.spines[spine].set_visible(False)
    plt.show();
```

Función para determinar las derivadas de las funciones

```python
def deriv(y, t, N, beta, gamma, delta):
    S, E, I, R = y
    dSdt = -beta * S * I / N
    dEdt = beta * S * I / N - delta * E
    dIdt = delta * E - gamma * I
    dRdt = gamma * I
    return dSdt, dEdt, dIdt, dRdt
```

Determinación de los valores iniciales

```python
N = 1000000
D = 4.0 # infections lasts four days
gamma = 1.0 / D
delta = 1.0 / 5.0  # incubation period of three days
R_0 = 5.0
beta = R_0 * gamma # infected person infects 1 other person per day

S0, E0, I0, R0 = N-1, 1, 0, 0  # initial conditions: one exposed
```

Cálculo de $S,E,I$ y $R$ para todo el tiempo de simulación (100 días)

```python
t = np.linspace(0, 100, 100) # Grid of time points (in days)
y0 = S0, E0, I0, R0 # Initial conditions vector

# Integrate the SIR equations over the time grid, t.
ret = odeint(deriv, y0, t, args=(N, beta, gamma, delta))
S, E, I, R = ret.T
```

```python
plotsir(t, S, E, I, R)
```

Cada vez tiene mejor cara... pero aún falta lo más cruel de todo, representar los posibles decesos.

## Obteniendo el compartimiento de "*Decesos*"

Recordemos el modelo $S.E.I.R.$

¿Cuándo puede morir una persona por la enfermedad? ¡Sólo mientras esté infectado!, esto significa que hay qué agregar una transición $I \rightarrow D$.

Por supuesto, las personas no mueren de inmediato; Definimos una nueva variable $\rho$(rho) para la tasa a la que las personas mueren (por ejemplo, cuando tarda $6$ días en morir, $\rho=1/6$). No hay razón para que la tasa de recuperación, $\gamma$, cambie. Entonces, nuestro nuevo modelo se verá:

Lo único que falta son las probabilidades de pasar de infectado a recuperado y de infectado a muerto. Esa será una variable más (¡la última por ahora!), La tasa de mortalidad $\alpha$. Por ejemplo, si $\alpha= 5\%$, $\rho = 1$ y $\gamma=1$ (por lo que las personas mueren o se recuperan en $1$ día, eso es un ejemplo más fácil) y $100$ personas están infectadas, entonces $5\% \times 100 = 5$ personas morirán. Eso deja $95\% \times 100 = 95$ personas en recuperación. En resumen, la probabilidad de $I \rightarrow D$ es $\alpha$ y, por lo tanto, la probabilidad de $I \rightarrow R$ es $1-\alpha$. Finalmente llegamos a este modelo:

Adicionando esta nueva ecuación al conjunto que teníamos de antes:

$$
\begin{align}
\frac{dS}{dt} & = -\beta \cdot I \cdot \frac{S}{N} \\
\frac{dE}{dt} & = \beta \cdot I \cdot \frac{S}{N} - \delta \cdot E\\
\frac{dI}{dt} & = \delta \cdot E - (1-\alpha) \cdot \gamma \cdot I - \alpha \cdot \rho \cdot I\\
\frac{dR}{dt} & = (1-\alpha) \cdot \gamma \cdot I \\
\frac{dD}{dt} & = \alpha \cdot \rho \cdot I
\end{align}
$$

## Programando el compartimiento de *Decesos*

Modificando el código que venimos trayendo para incluir los decesos. Solo es necesario realizar algunos cambios menores, como es incluir $\alpha=20\%$ y $\rho=1/9$:

```python
def deriv(y, t, N, beta, gamma, delta, alpha, rho):
    S, E, I, R, D = y
    dSdt = -beta * S * I / N
    dEdt = beta * S * I / N - delta * E
    dIdt = delta * E - (1 - alpha) * gamma * I - alpha * rho * I
    dRdt = (1 - alpha) * gamma * I
    dDdt = alpha * rho * I
    return dSdt, dEdt, dIdt, dRdt, dDdt
```

```python
D = 4.0 # infections lasts four days
gamma = 1.0 / D
delta = 1.0 / 5.0  # incubation period of five days
R_0 = 5.0
beta = R_0 * gamma  # R_0 = beta / gamma, so beta = R_0 * gamma
alpha = 0.2  # 10% death rate
rho = 1/9  # 9 days from infection until death
S0, E0, I0, R0, D0 = N-1, 1, 0, 0, 0  # initial conditions: one exposed
```

```python
t = np.linspace(0, 100, 100) # Grid of time points (in days)
y0 = S0, E0, I0, R0, D0 # Initial conditions vector

# Integrate the SIR equations over the time grid, t.
ret = odeint(deriv, y0, t, args=(N, beta, gamma, delta, alpha, rho))
S, E, I, R, D = ret.T
```

```python
def plotseird(t, S, E, I, R, D):
    f, ax = plt.subplots(1,1,figsize=(10,4))
    ax.plot(t, S, 'b', alpha=0.7, linewidth=2, label='Susceptible')
    ax.plot(t, E, 'y', alpha=0.7, linewidth=2, label='Exposed')
    ax.plot(t, I, 'r', alpha=0.7, linewidth=2, label='Infected')
    ax.plot(t, R, 'g', alpha=0.7, linewidth=2, label='Recovered')
    ax.plot(t, D, 'k', alpha=0.7, linewidth=2, label='Dead')
    ax.plot(t, S+E+I+R+D, 'c--', alpha=0.7, linewidth=2, label='Total')

    ax.set_xlabel('Time (days)')

    ax.yaxis.set_tick_params(length=0)
    ax.xaxis.set_tick_params(length=0)
    ax.grid(b=True, which='major', c='w', lw=2, ls='-')
    legend = ax.legend()
    legend.get_frame().set_alpha(0.5)
    for spine in ('top', 'right', 'bottom', 'left'):
        ax.spines[spine].set_visible(False)
    plt.show();
```

```python
plotseird(t, S, E, I, R, D)
```

Se adicionó una variable "*total*" que suma $S, E, I, R$ y $D$ para cada paso de tiempo como un "*control de sanidad*": los compartimentos siempre tienen que sumar $N$; Esto puede dar una pista sobre si las ecuaciones son correctas.

Ya sabemos cómo ir agregando nuevos compartimentos al modelo: piense qué transiciones deben agregarse y cambiarse; pensar en las probabilidades, poblaciones y tasas de estas nuevas transiciones; dibuja el diagrama; y finalmente escribe las ecuaciones. ¡La codificación definitivamente no es la parte difícil para estos modelos!

Por ejemplo, es posible que desee agregar un compartimento "*UCI*" para las personas infectadas que necesitan ir a una *UCI* (lo haremos en el próximo artículo). Piense desde qué compartimiento las personas pueden ir a la *UCI*, a dónde pueden ir después de la *UCI*, etc.

# Variables dependientes del tiempo

Resumamos el listado de las variables empleadas hasta ahora:

- $N$: población total

- $S(t)$: número de personas susceptibles en el día $t$

- $E(t)$: número de personas expuestas el día $t$

- $I(t)$: número de personas infectadas el día $t$

- $R(t)$: número de personas recuperadas el día $t$

- $D(t)$: número de personas fallecidas el día $t$

- $\beta$: cantidad esperada de personas que una persona infectada infecta por día

- $D$: número de días que una persona infectada tiene y puede transmitir la enfermedad

- $\gamma$: la proporción de infectados que se recuperan por día ($\gamma = 1/D$)

- $R_0$: el número total de personas que infecta una persona infectada ($R_0= \beta / \gamma$)

- $\delta$: duración del período de incubación

- $\alpha$: tasa de mortalidad

- $\rho$: tasa a la que muere la gente (= 1 / días desde la infección hasta la muerte)

Como puede ver, solo los compartimentos cambian con el tiempo (no son constantes). ¡Por supuesto, esto es muy poco realista! Como ejemplo, ¿por qué el valor $R_0$ debería ser constante? Seguramente, los bloqueos a nivel nacional reducen la cantidad de personas que infecta una persona infectada, ¡de eso se trata! Naturalmente, para acercarnos a modelar desarrollos del mundo real, tenemos que hacer que nuestras variables cambien con el tiempo.

Esto hará parte de la continuación de este trabajo.

# Muchas gracias por su atención!!!


```python
from IPython.display import Image
Image('../../../python_for_probability_statistics_and_machine_learning.jpg')
```

[Python for Probability, Statistics, and Machine Learning](https://www.springer.com/fr/book/9783319307152)

```python
from pprint import pprint
import textwrap
import sys, re
```

# Useful Inequalities

In practice, few quantities can be analytically calculated. Some knowledge
of bounding inequalities helps find the ballpark for potential solutions. This
sections discusses three key inequalities that are important for
probability, statistics, and machine learning.

## Markov's Inequality

Let $X$ be a non-negative random variable
and suppose that $\mathbb{E}(X) < \infty$. Then,
for any $t>0$,

$$
\mathbb{P}(X>t)\leq \frac{\mathbb{E}(X)}{t}
$$

 This is a foundational inequality that is
used as a stepping stone to other inequalities. It is easy
to prove. Because $X>0$, we have the following,

$$
\begin{align*}
\mathbb{E}(X)&=\int_0^\infty x f_x(x)dx =\underbrace{\int_0^t x f_x(x)dx}_{\text{omit this}}+\int_t^\infty x f_x(x)dx \\\
             &\ge\int_t^\infty x f_x(x)dx \ge t\int_t^\infty x f_x(x)dx = t \mathbb{P}(X>t)
\end{align*}
$$

 The step that establishes the inequality is the part where the
$\int_0^t x f_x(x)dx$ is omitted.  For a particular $f_x(x)$ that my be
concentrated around the $[0,t]$ interval, this could be a lot to throw out.
For that reason, the Markov Inequality is considered a *loose* inequality,
meaning that there is a substantial gap between both sides of the inequality.
For example, as shown in [Figure](#fig:ProbabilityInequalities_001), the
$\chi^2$ distribution has a lot of its mass on the left, which would be omitted
in the  Markov Inequality. [Figure](#fig:ProbabilityInequalities_002) shows
the two curves established by the Markov Inequality. The gray shaded region is
the gap between the two terms and indicates that looseness of the bound
(fatter shaded region) for this case.

The $\chi_1^2$ density has much of its weight on the left, which is excluded in the establishment of the Markov Inequality.

The shaded area shows the region between the curves on either side of the Markov Inequality.

## Chebyshev's Inequality

Chebyshev's Inequality drops out directly from the Markov Inequality.  Let
$\mu=\mathbb{E}(X)$ and $\sigma^2=\mathbb{V}(X)$. Then, we have

$$
\mathbb{P}(\vert X-\mu\vert \ge t) \le \frac{\sigma^2}{t^2}
$$

 Note that if we normalize so that $Z=(X-\mu)/\sigma$, we
have $\mathbb{P}(\vert Z\vert \ge k) \le 1/k^2$. In particular,
$\mathbb{P}(\vert Z\vert \ge 2) \le 1/4$. We can illustrate this
inequality using Sympy statistics module,

```python
import sympy
import sympy.stats as ss
t=sympy.symbols('t',real=True)
x=ss.ChiSquared('x',1)
```

  To get the left side of the Chebyshev inequality, we
have to write this out as the following conditional probability,

```python
r = ss.P((x-1) > t,x>1)+ss.P(-(x-1) > t,x<1)
```

 This is because of certain limitations in the statistics module at
this point in its development regarding the absolute value function. We could
take the above expression, which is a function of $t$ and attempt to compute
the integral, but that would take a very long time (the expression is very long
and complicated, which is why we did not print it out above). This is because
Sympy is a pure-python module that does not utilize any C-level optimizations
under the hood.  In this situation, it's better to use the built-in cumulative
density function as in the following (after some rearrangement of the terms),

```python
w=(1-ss.cdf(x)(t+1))+ss.cdf(x)(1-t)
```

 To plot this, we can evaluated at a variety of `t` values by using
the `.subs` substitution method, but it is more convenient to use the
`lambdify` method to convert the expression to a function.

```python
fw=sympy.lambdify(t,w)
```

 Then, we can evaluate this function using something like

```python
map(fw,[0,1,2,3,4])
```

    [1.0,
     0.1572992070502851,
     0.08326451666355028,
     0.04550026389635853,
     0.0253473186774682]

 to produce the following [Figure](#fig:ProbabilityInequalities_003).

The shaded area shows the region between the curves on either side of the Chebyshev Inequality.

**Programming Tip.**

Note that we cannot use vectorized inputs for the `lambdify` function because
it contains embedded functions that are only available in Sympy. Otherwise, we
could have used `lambdify(t,fw,numpy)` to specify the corresponding functions
in Numpy to use for the expression.

## Hoeffding's Inequality

Hoeffding's Inequality is similar, but less loose, than Markov's Inequality.
Let $X_1,\ldots,X_n$ be iid observations such that $\mathbb{E}(X_i)=\mu$ and
$a\le X_i \le b$. Then, for any $\epsilon>0$, we have

$$
\mathbb{P}(\vert \overline{X}_n -\mu\vert \ge \epsilon) \le 2 \exp(-2 n\epsilon^2/(b-a)^2)
$$

 where $\overline{X}_n = \tfrac{1}{n}\sum_i^n X_i$. Note that we
further assume that the individual random variables are bounded.

**Corollary.** If $X_1,\ldots,X_n$ are independent with $\mathbb{P}(a\le X_i\le b)=1$
and all with $\mathbb{E}(X_i)=\mu$. Then, we have

$$
\vert\overline{X}_n-\mu\vert \le \sqrt{\frac{c}{2 n}\log \frac{2}{\delta}}
$$

 where $c=(b-a)^2$. We will see this inequality again in the machine
learning chapter. [Figure](#fig:ProbabilityInequalities_004) shows the Markov
and Hoeffding bounds for the case of ten identically and uniformly distributed
random variables, $X_i \sim \mathcal{U}[0,1]$.  The solid line shows
$\mathbb{P}(\vert \overline{X}_n - 1/2 \vert > \epsilon)$.  Note that the
Hoeffding Inequality is tighter than the Markov Inequality and that both of
them merge when $\epsilon$ gets big enough.

This shows the Markov and Hoeffding bounds for the case of ten identically and uniformly distributed random variables.


# Hemoglobin Model Construction

Based on Chapter 13 of Systems Biology: Simulation of Dynamic Network States

To construct a module of hemoglobin, first we import **MASSpy** and other essential packages. Constants used throughout the notebook are also defined.

```python
from os import path

import matplotlib.pyplot as plt

from sympy import Equality, Symbol, solveset, sympify, pprint

from cobra import DictList

from mass import (
    MassConfiguration, MassMetabolite, MassModel,
    MassReaction, Simulation, UnitDefinition)
from mass.example_data import create_example_model
from mass.io import json, sbml
from mass.util import strip_time, qcqa_model

mass_config = MassConfiguration()

mass_config.irreversible_Keq = float("inf")
```

## Model Construction

The first step of creating a model of hemoglobin is to define the `MassModel`.

```python
hemoglobin = MassModel("Hemoglobin")
```

    Set parameter Username

### Metabolites

The next step is to define all of the metabolites using the `MassMetabolite` object. Some considerations for this step include the following:

1. It is important to use a clear and consistent format for identifiers and names when defining the `MassMetabolite` objects for various reasons, some of which include improvements to model clarity and utility, assurance of unique identifiers (required to add metabolites to the model), and consistency when collaborating and communicating with others.

2. In order to ensure our model is physiologically accurate, it is important to provide the `formula` argument with a string representing the chemical formula for each metabolite, and the `charge` argument with an integer representing the metabolite's ionic charge (Note that neutrally charged metabolites are provided with 0). These attributes can always be set later if necessary using the `formula` and `charge` attribute set methods. To include the Hemoglobin macromolecule in the formula, brackets are used (e.g., [HB]).

3. To indicate that the cytosol is the cellular compartment in which the reactions occur, the string "c" is provided to the `compartment` argument.

This model will be created using identifiers and names found in the [BiGG Database](http://bigg.ucsd.edu/).

In this model, there are 13 metabolites inside the cytosol compartment. Note that for metabolites without BiGG identifiers are given ones that are similar to BiGG style.

```python
hb_c = MassMetabolite(
    "hb_c",
    name="Hemoglobin",
    formula="[HB]",
    charge=0,
    compartment="c",
    fixed=False)

hb_1o2_c = MassMetabolite(
    "hb_1o2_c",
    name="Oxyhemoglobin (1)",
    formula="[HB]-O2",
    charge=0,
    compartment="c",
    fixed=False)

hb_2o2_c = MassMetabolite(
    "hb_2o2_c",
    name="Oxyhemoglobin (2)",
    formula="[HB]-O4",
    charge=0,
    compartment="c",
    fixed=False)

hb_3o2_c = MassMetabolite(
    "hb_3o2_c",
    name="Oxyhemoglobin (3)",
    formula="[HB]-O6",
    charge=0,
    compartment="c",
    fixed=False)

hb_4o2_c = MassMetabolite(
    "hb_4o2_c",
    name="Oxyhemoglobin (4)",
    formula="[HB]-O8",
    charge=0,
    compartment="c",
    fixed=False)

dhb_c = MassMetabolite(
    "dhb_c",
    name="Deoxyhemoglobin",
    formula="[HB]-C3H3O10P2",
    charge=-5,
    compartment="c",
    fixed=False)

_23dpg_c = MassMetabolite(
    "_23dpg_c",
    name="2,3-Disphospho-D-glycerate",
    formula="C3H3O10P2",
    charge=-5,
    compartment="c",
    fixed=False)

_13dpg_c = MassMetabolite(
    "_13dpg_c",
    name="3-Phospho-D-glyceroyl phosphate",
    formula="C3H4O10P2",
    charge=-4,
    compartment="c",
    fixed=False)

_3pg_c = MassMetabolite(
    "_3pg_c",
    name="3-Phospho-D-glycerate",
    formula="C3H4O7P",
    charge=-3,
    compartment="c",
    fixed=False)

o2_c = MassMetabolite(
    "o2_c",
    name="Oxygen",
    formula="O2",
    charge=0,
    compartment="c",
    fixed=False)

h_c = MassMetabolite(
    "h_c",
    name="H+",
    formula="H",
    charge=1,
    compartment="c",
    fixed=False)

pi_c = MassMetabolite(
    "pi_c",
    name="Phosphate",
    formula="HPO4",
    charge=-2,
    compartment="c",
    fixed=False)

h2o_c = MassMetabolite(
    "h2o_c",
    name="H2O",
    formula="H2O",
    charge=0,
    compartment="c",
    fixed=False)
```

### Reactions

Once all of the `MassMetabolite` objects for each metabolite, the next step is to define all of the reactions that occur and their stoichiometry.

1. As with the metabolites, it is also important to use a clear and consistent format for identifiers and names when defining when defining the `MassReaction` objects.

2. To make this model useful for integration with other models, it is important to provide a string to the `subsystem` argument. By providing the subsystem, the reactions can be easily obtained even when integrated with a significantly larger model through the `subsystem` attribute.

3. After the creation of each `MassReaction` object, the metabolites are added to the reaction using a dictionary where keys are the `MassMetabolite` objects and values are the stoichiometric coefficients (reactants have negative coefficients, products have positive ones).

This model will be created using identifiers and names found in the [BiGG Database](http://bigg.ucsd.edu/).

In this model, there are 7 reactions occuring inside the cytosol compartment.

```python
DPGase = MassReaction(
    "DPGase",
    name="Diphosphoglycerate phosphatase",
    subsystem=hemoglobin.id,
    reversible=False)
DPGase.add_metabolites({
    h2o_c: -1,
    _23dpg_c: -1,
    _3pg_c: 1,
    pi_c: 1})

DPGM = MassReaction(
    "DPGM",
    name="Diphosphoglyceromutase",
    subsystem=hemoglobin.id,
    reversible=True)
DPGM.add_metabolites({
    _13dpg_c: -1,
    _23dpg_c: 1,
    h_c: 1})

HBDPG = MassReaction(
    "HBDPG",
    name="Hemoglobin-23dpg binding",
    subsystem=hemoglobin.id,
    reversible=True)
HBDPG.add_metabolites({
    hb_c: -1,
    _23dpg_c: -1,
    dhb_c: 1})

HBO1 = MassReaction(
    "HBO1",
    name="Oxygen Loading (1)",
    subsystem=hemoglobin.id,
    reversible=True)
HBO1.add_metabolites({
    hb_c: -1,
    o2_c: -1,
    hb_1o2_c: 1})

HBO2 = MassReaction(
    "HBO2",
    name="Oxygen Loading (2)",
    subsystem=hemoglobin.id,
    reversible=True)
HBO2.add_metabolites({
    hb_1o2_c: -1,
    o2_c: -1,
    hb_2o2_c: 1})

HBO3 = MassReaction(
    "HBO3",
    name="Oxygen Loading (3)",
    subsystem=hemoglobin.id,
    reversible=True)
HBO3.add_metabolites({
    hb_2o2_c: -1,
    o2_c: -1,
    hb_3o2_c: 1})

HBO4 = MassReaction(
    "HBO4",
    name="Oxygen Loading (4)",
    subsystem=hemoglobin.id,
    reversible=True)
HBO4.add_metabolites({
    hb_3o2_c: -1,
    o2_c: -1,
    hb_4o2_c: 1})
```

After generating the reactions, all reactions are added to the model through the `MassModel.add_reactions` class method. Adding the `MassReaction` objects will also add their associated `MassMetabolite` objects if they have not already been added to the model.

```python
hemoglobin.add_reactions([
    DPGase, DPGM, HBDPG, HBO1, HBO2, HBO3, HBO4])

for reaction in hemoglobin.reactions:
    print(reaction)
```

    DPGase: _23dpg_c + h2o_c --> _3pg_c + pi_c
    DPGM: _13dpg_c <=> _23dpg_c + h_c
    HBDPG: _23dpg_c + hb_c <=> dhb_c
    HBO1: hb_c + o2_c <=> hb_1o2_c
    HBO2: hb_1o2_c + o2_c <=> hb_2o2_c
    HBO3: hb_2o2_c + o2_c <=> hb_3o2_c
    HBO4: hb_3o2_c + o2_c <=> hb_4o2_c

### Boundary reactions

After generating the reactions, the next step is to add the boundary reactions and boundary conditions (the concentrations of the boundary 'metabolites' of the system). This can easily be done using the `MassModel.add_boundary` method. With the generation of the boundary reactions, the system becomes an open system, allowing for the flow of mass through the biochemical pathways of the model. Once added, the model will be able to return the boundary conditions as a dictionary through the `MassModel.boundary_conditions` attribute.

All boundary reactions are originally created with the metabolite as the reactant. However, there are times where it would be preferable to represent the metabolite as the product. For these situtations, the `MassReaction.reverse_stoichiometry` method can be used with its `inplace` argument to create a new `MassReaction` or simply reverse the stoichiometry for the current `MassReaction.`

In this model, there is 1 boundary reaction that must be defined.

```python
SK_o2_c = hemoglobin.add_boundary(
    metabolite=o2_c, boundary_type="sink", subsystem="Pseudoreaction",
    boundary_condition=0.0200788)

print("Boundary Reactions and Values\n-----------------------------")
for reaction in hemoglobin.boundary:
    boundary_met = reaction.boundary_metabolite
    bc_value = hemoglobin.boundary_conditions.get(boundary_met)
    print("{0}\n{1}: {2}\n".format(
        reaction, boundary_met, bc_value))
```

    Boundary Reactions and Values
    -----------------------------
    SK_o2_c: o2_c <=>
    o2_b: 0.0200788

### Ordering of internal species and reactions

Sometimes, it is also desirable to reorder the metabolite and reaction objects inside the model to follow the physiology. To reorder the internal objects, one can use `cobra.DictList` containers and the `DictList.get_by_any` method with the list of object identifiers in the desirable order. To ensure all objects are still present and not forgotten in the model, a small QA check is also performed.

```python
new_metabolite_order = [
    "_23dpg_c", "hb_c", "hb_1o2_c", "hb_2o2_c",
    "hb_3o2_c", "hb_4o2_c", "dhb_c", "_13dpg_c",
    "_3pg_c",  "o2_c", "pi_c", "h_c", "h2o_c"]

if len(hemoglobin.metabolites) == len(new_metabolite_order):
    hemoglobin.metabolites = DictList(
        hemoglobin.metabolites.get_by_any(new_metabolite_order))

new_reaction_order = [
    "DPGM", "DPGase", "HBO1", "HBO2",
    "HBO3", "HBO4", "HBDPG", "SK_o2_c"]

if len(hemoglobin.reactions) == len(new_reaction_order):
    hemoglobin.reactions = DictList(
        hemoglobin.reactions.get_by_any(new_reaction_order))

hemoglobin.update_S(array_type="DataFrame", dtype=int)
```

      DPGM
      DPGase
      HBO1
      HBO2
      HBO3
      HBO4
      HBDPG
      SK_o2_c

      _23dpg_c
      1
      -1
      0
      0
      0
      0
      -1
      0

      hb_c
      0
      0
      -1
      0
      0
      0
      -1
      0

      hb_1o2_c
      0
      0
      1
      -1
      0
      0
      0
      0

      hb_2o2_c
      0
      0
      0
      1
      -1
      0
      0
      0

      hb_3o2_c
      0
      0
      0
      0
      1
      -1
      0
      0

      hb_4o2_c
      0
      0
      0
      0
      0
      1
      0
      0

      dhb_c
      0
      0
      0
      0
      0
      0
      1
      0

      _13dpg_c
      -1
      0
      0
      0
      0
      0
      0
      0

      _3pg_c
      0
      1
      0
      0
      0
      0
      0
      0

      o2_c
      0
      0
      -1
      -1
      -1
      -1
      0
      -1

      pi_c
      0
      1
      0
      0
      0
      0
      0
      0

      h_c
      1
      0
      0
      0
      0
      0
      0
      0

      h2o_c
      0
      -1
      0
      0
      0
      0
      0
      0

### Computing the steady state concentrations.

The binding of the two ligands, oxygen and DPG23, to hemoglobin is a rapid process. Since hemoglobin is confined to the RBC, we can use equilibrium assumptions for the binding reactions.

1. The binding of oxygen is at equilibrium for each form of oxygenated hemoglobin.
2. The binding of DPG23 to hemoglobin is also at equilibrium
3. The total mass of hemoglobin is a constant

These six equations have six unknowns (the six forms of Hb) and need to be solved simultaneously as a function of the oxygen and DPG23 concentrations. The equilibrium relationships can be combined with the $\text{Hb}_{\mathrm{tot}}$ mass balance, and this equation is solved for  $\text{Hb}_{\mathrm{0}}$ for given oxygen and 23DPG concentrations. Then the steady state concentrations for all other forms of hemoglobin can be computed from the equilibrium relationships.

To do this, the **SymPy** package is utilized. The metabolites and equilibrium constants are defined as `sympy.Symbol` objects, and then the equilibrium expressions are converted into `sympy.Equality` objects for symbolic calculations.

```python
metabolites = {metabolite.id: Symbol(metabolite.id)
               for metabolite in hemoglobin.metabolites}

concentration_equations = {}
# Iterate through reactions assumed to be at equilibrium
for reaction in [HBO1, HBO2, HBO3, HBO4, HBDPG]:
    equilibrium_expression = Equality(
        Symbol(reaction.Keq_str),
        strip_time(reaction.get_mass_action_ratio()))
    # Find the hemoglobin form being made as a product (bound to most oxygen)
    hb_product = [
        Symbol(metabolite.id) for metabolite in reaction.products
        if metabolite.id not in ["_23dpg_c", "hb_c", "o2_c"]].pop()
    # Solve equation for the desired form hemoglobin
    equation = solveset(equilibrium_expression, hb_product)
    equation = next(iter(equation))
    # Update equilibrium expression dict with the equation
    # for the bound form of hemoglobin. These equations will
    # be dependent on hb_c, o2_c, and _23dpg_c.
    concentration_equations.update({
        hb_product: equation.subs(concentration_equations)})
# Specify an equation for the total amount of hemoglobin
HB_total_symbol = Symbol("HB-Total")
HB_total = Equality(
    HB_total_symbol,
    sympify("+".join([
        metabolite.id for metabolite in hemoglobin.metabolites
        if "hb" in metabolite.id]), locals=metabolites))
HB_total = HB_total.subs(concentration_equations)
pprint(HB_total)
```

                                                                                4
    HB-Total = Keq_HBDPG⋅_23dpg_c⋅hb_c + Keq_HBO1⋅Keq_HBO2⋅Keq_HBO3⋅Keq_HBO4⋅hb_c⋅o_2_c  + Keq_HBO1⋅Keq_HBO2

                        3                               2
    ⋅Keq_HBO3⋅hb_c⋅o_2_c  + Keq_HBO1⋅Keq_HBO2⋅hb_c⋅o_2_c  + Keq_HBO1⋅hb_c⋅o_2_c + hb_c

At this point, the numerical values for the equilibrium constant and the total concetration of hemoglobin are specified. The total amount of hemoglobin is a constant, at circa 7.3 mM. These values are substituted into the current equations.

```python
numerical_values = {HB_total_symbol: 7.3}

DPGM.Keq = 2.3*1e6
HBO1.Keq = 41.8352
HBO2.Keq = 73.2115
HBO3.Keq = 177.799
HBO4.Keq = 1289.92
HBDPG.Keq = 1/4
SK_o2_c.Keq = 1

numerical_values.update({
    Symbol(reaction.Keq_str): reaction.Keq
    for reaction in hemoglobin.reactions})

concentration_equations.update({
    hb_form: equation.subs(numerical_values)
    for hb_form, equation in concentration_equations.items()})
HB_total = HB_total.subs(numerical_values)
pprint(HB_total)
```

                                                         4                              3
    7.3 = 0.25⋅_23dpg_c⋅hb_c + 702446487.27335⋅hb_c⋅o_2_c  + 544565.932207695⋅hb_c⋅o_2_c  + 3062.8177448⋅hb_

           2
    c⋅o_2_c  + 41.8352⋅hb_c⋅o_2_c + hb_c

To find the steady state, we have to specify the numerical values of the variables that characterize the network environment. The flux through the Rapoport-Luebering shunt is typically about 0.44 mM/hr (Schrader 1993).  The steady state concentration of 23DPG is typically about 3.1 mM (Mehta 2005). The concentration of oxygen that we chose to solve for the steady state is 70 mmHg, that is mid way between 100 mmHg in the lung, and 40 mmHg in tissue. Using these numbers, the computed steady state concentrations are obtained, as:

```python
# Define known concentrations
concentrations = {
    metabolites["_23dpg_c"]: 3.1,
    metabolites["o2_c"]: 70*2.8684*1e-4}
# Convert the solution into a numerical value
hb_conc =  next(iter(solveset(
    HB_total.subs(concentrations),
    Symbol("hb_c"))))
concentrations.update({metabolites["hb_c"]: hb_conc})
# Solve for the rest of the hemoglobin concentrations
for hb_form, equation in concentration_equations.items():
    equation = equation.subs(concentrations)
    concentrations.update({hb_form: equation})
```

Once the steady state concentrations have been determined, the hemoglobin module can be updated. The remaining concentrations are obtained from the glycolysis module.

```python
glycolysis = create_example_model("SB2_Glycolysis.json")

for metabolite_symbol, value_symbol in concentrations.items():
    metabolite = hemoglobin.metabolites.get_by_id(str(metabolite_symbol))
    metabolite.ic = float(value_symbol)

for met in hemoglobin.metabolites:
    if met.ic is None:
        met.ic = glycolysis.metabolites.get_by_id(str(met)).ic

for metabolite, concentration in hemoglobin.initial_conditions.items():
    print("{0}: {1:.6f}".format(metabolite, concentration))
```

    _23dpg_c: 3.100000
    hb_c: 0.059625
    hb_1o2_c: 0.050085
    hb_2o2_c: 0.073625
    hb_3o2_c: 0.262842
    hb_4o2_c: 6.807613
    dhb_c: 0.046210
    _13dpg_c: 0.000243
    _3pg_c: 0.077300
    o2_c: 0.020079
    pi_c: 2.500000
    h_c: 0.000090
    h2o_c: 1.000000

With the steady state concentrations and steady state flux values, the PERCs can be calculated. For this module, the PERCs for the binding of hemoglobin to oxygen will be set manually to better reflect the physiology.

__Note:__ Reactions at equilibrium have a steady state flux of 0.

```python
DPGM.v = 0.441
DPGase.v = 0.441
HBO1.v = 0
HBO2.v = 0
HBO3.v = 0
HBO4.v = 0
HBDPG.v = 0
SK_o2_c.v = 0

hemoglobin.calculate_PERCs(update_reactions=True)

HBO1.kf = 506935
HBO2.kf = 511077
HBO3.kf = 509243
HBO4.kf = 501595
HBDPG.kf =519613
SK_o2_c.kf = 509726
```

## QC/QA Model

Before simulating the model, it is important to ensure that the model is elementally balanced, and that the model can simulate. Therefore, the `qcqa_model` function from `mass.util.qcqa` is used to provide a report on the model quality and indicate whether simulation is possible and if not, what parameters and/or initial conditions are missing.

```python
qcqa_model(hemoglobin,  parameters=True, concentrations=True,
           fluxes=True, superfluous=True, elemental=True)
```

    ╒══════════════════════════════════════════╕
    │ MODEL ID: Hemoglobin                     │
    │ SIMULATABLE: True                        │
    │ PARAMETERS NUMERICALY CONSISTENT: True   │
    ╞══════════════════════════════════════════╡
    ╘══════════════════════════════════════════╛

From the results of the QC/QA test, it can be seen that the model can be simulated and is numerically consistent.

## Steady State and Model Validation

In order to determine whether the module can be successfully integrated into a model, another model can be loaded, merged with the module, and simulated. To validate this module, it will be merged with a glycolysis model.

To find the steady state of the model and perform simulations, the model must first be loaded into a `Simulation`. In order to load a model into a `Simulation`, the model must be simulatable, meaning there are no missing numerical values that would prevent the integration of the ODEs that comprise the model. The `verbose` argument can be used while loading a model to produce a message indicating the successful loading of a model, or why a model could not load.

Once loaded into a `Simulation`, the `find_steady_state` method can be used with the `update_values` argument in order to update the initial conditions and fluxes of the model to a steady state (if necessary). The model can be simulated using the `simulate` method by passing the model to simulate, and a tuple containing the start time and the end time. The number of time points can also be included, but is optional.

After a successful simulation, two `MassSolution` objects are returned. The first `MassSolution` contains the concentration results of the simulation, and the second contains the flux results of the simulation.

To visually validate the steady state of the model, concentration and flux solutions can be plotted using the `plot_time_profile` function from `mass.visualization`. Alternatively, the `MassSolution.view_time_profile` property can be used to quickly generate a time profile for the results.

```python
glyc_hb = glycolysis.merge(hemoglobin, inplace=False)

# Setup simulation object, ensure model is at steady state
sim = Simulation(glyc_hb, verbose=True)
sim.find_steady_state(glyc_hb, strategy="simulate", update_values=True)
# Simulate from 0 to 1000 with 10001 points in the output
conc_sol, flux_sol = sim.simulate(glyc_hb, time=(0, 1e3))
# Quickly render and display time profiles
conc_sol.view_time_profile()
```

### Storing information and references
#### Compartment
Because the character "c" represents the cytosol compartment, it is recommended to define and set the compartment in the `MassModel.compartments` attribute.

```python
hemoglobin.compartments = {"c": "Cytosol"}
print(hemoglobin.compartments)
```

    {'c': 'Cytosol'}

#### Units
All of the units for the numerical values used in this model are "Millimoles" for amount and "Liters" for volume (giving a concentration unit of 'Millimolar'), and "Hours" for time. In order to ensure that future users understand the numerical values for model, it is important to define the `MassModel.units` attribute.

The `MassModel.units` is a `cobra.DictList` that contains only `UnitDefinition` objects from the `mass.core.unit` submodule. Each `UnitDefinition` is created from `Unit` objects representing the base units that comprise the `UnitDefinition`. These `Units` are stored in the `list_of_units` attribute. Pre-built units can be viewed using the `print_defined_unit_values` function from the `mass.core.unit` submodule. Alternatively, custom units can also be created using the `UnitDefinition.create_unit` method. For more information about units, please see the module docstring for `mass.core.unit` submodule.

__Note:__ It is important to note that this attribute will NOT track units, but instead acts as a reference for the user and others so that they can perform necessary unit conversions.

```python
# Using pre-build units to define UnitDefinitions
concentration = UnitDefinition("mM", name="Millimolar",
                               list_of_units=["millimole", "per_litre"])
time = UnitDefinition("hr", name="hour", list_of_units=["hour"])

# Add units to model
hemoglobin.add_units([concentration, time])
print(hemoglobin.units)
```

    [<UnitDefinition Millimolar "mM" at 0x7fc6313cd1f0>, <UnitDefinition hour "hr" at 0x7fc6313cd100>]

## Export

After validation, the model is ready to be saved. The model can either be exported as a ".json" file or as an ".sbml" (".xml") file using their repsective submodules in `mass.io`.

To export the model, only the path to the directory and the model object itself need to be specified.

### Export using SBML

```python
sbml.write_sbml_model(mass_model=hemoglobin, filename="SB2_" + hemoglobin.id + ".xml")
```

### Export using JSON

```python
json.save_json_model(mass_model=hemoglobin, filename="SB2_" + hemoglobin.id + ".json")
```


# SpectralDNS

<!-- img src="../images/KHmovie2.gif" width="250"  -->

**Rayleigh-Bénard** convection computed with spectral accuracy using a [spectralDNS solver](https://github.com/spectralDNS/spectralDNS/blob/master/demo/RayleighBenard.py).

The [spectralDNS](https://github.com/spectralDNS) project revolves around
implementing high-performance flow solvers in [Python](https://www.python.org), which is a modern and very
high-level programming language. The project is supported through several grants from [King
Abdullahs University of Science and Technology](https://www.hpc.kaust.edu.sa),
granting access to some of the world's largest supercomputers. The work has
been presented at several conferences and as invited talks

  * [11'th International Conference on Scientific Computing and Applications](http://tianyuan.xmu.edu.cn/activities/19-20/ICSCA2019/index.html)

  * [International Conference on Computational Science and Engineering](https://cseconf2017.simula.no)

  * [I3MS Seminar Series of the Institute for Modeling and Simulation, 6 Nov 2017 RWTH AAchen University](https://www.aices.rwth-aachen.de/en/media-and-seminars/events/mortensen-seminar)

  * [Predictive Complex Computational Fluid Dynamics, KAUST, May 2017](https://pccfd.kaust.edu.sa/speaker?si=4)

  * [MekIT'17 National Conference on computational Mechanics, Trondheim, May 2017](http://arxiv.org/abs/1708.03188)

  * [EuroScipy, Cambridge, August 2015](https://www.euroscipy.org/2015/schedule/presentation/6/)

The *spectralDNS* project on github contains several repositories, each representing a smaller part of the overall project. The most important are presented beolw.

## spectralDNS

**Strong scaling** of triply periodic Navier-Stokes solver on the Shaheen II supercomputer at KAUST.

The [spectralDNS](https://github.com/spectralDNS/spectralDNS) repository is home to several different pseudo-spectral Navier-Stokes and MagnetoHydroDynamics solvers. Most solvers are for triply periodic domains. The simplest possible Navier-Stokes solver is described by {% cite Mortensen2016 %}, who show that a highly efficient solver can be created using no more than 100 lines of code, using nothing more than standard
tools like *Numpy* and *MPI for Python*. The DNS solver has been tested for a
transitional Taylor-Green vortex using a computational box of size $2048^3$. Accuracy is, well spectral, and in benchmark tests on the Shaheen II supercomputer at KAUST it has been found to scale well up to 64,000 cores.
A state-of-the-art spectral channel flow solver that is making extensive use of *shenfun*, has been described by {% cite mortensen2017spectral %}. Turbulent flow at $Re_{\tau}=2000$ is shown in the movie below.

With colleagues at the Extreme Computing Research Center (ECRC), King Abdullah University of Science and Technology (KAUST), we have been using [spectralDNS](https://github.com/spectralDNS) to investigate time integration of Fourier pseudospectral Direct Numerical Simulations {% cite ketcheson2020 %}. We investigate the use of higher‐order Runge‐Kutta pairs and automatic step size control based on local error estimation. We find that the fifth‐order accurate Runge‐Kutta pair of Bogacki and Shampine gives much greater accuracy at a significantly reduced computational cost.

## Shenfun

With the [shenfun](https://github.com/spectralDNS/shenfun) Python module
{% cite shenfun %} an
effort is made towards automating the implementation of the spectral Galerkin
method for simple tensor product domains, consisting of non-periodic and
periodic directions. The user interface to *shenfun* is intentionally made very
similar to [FEniCS](https://fenicsproject.org). Partial Differential Equations
are represented through weak variational forms and solved using efficient direct
solvers where available. MPI decomposition is achieved through the
[mpi4py-fft](https://bitbucket.org/mpi4py/mpi4py-fft) module, and all developed solvers may,
with no additional effort, be run on supercomputers using thousands of
processors.

An introduction to *shenfun* is given in {% cite shenfun %}, on [readthedocs](https://shenfun.readthedocs.io)
and the recent paper {% cite mortensen_joss %}. Introduction to *mpi4py-fft*
is given [here](https://mpi4py-fft.readthedocs.io/en/latest/) and
in {% cite mpi4py-fft_joss jpdc_fft %}. Further documentation is found at

[](https://shenfun.readthedocs.io/en/latest/)

Try shenfun in the computational cell below. Modify to own liking and run interactively.

```python
from sympy import symbols, cos, sin, pi
from shenfun import *
import matplotlib.pyplot as plt
%matplotlib inline

x, y = symbols("x,y")

C = Basis(20, 'Chebyshev')
F = Basis(24, 'Fourier', dtype='d')
T = TensorProductSpace(comm, (C, F))
u = project(sin(2*x)*cos(pi*y), T)
X = T.local_mesh(True)
plt.contourf(X[0], X[1], u.backward())
plt.colorbar()
plt.show()

```

## mpi4py-fft

mpi4py-fft is a Python package for computing Fast Fourier Transforms (FFTs).
Large arrays are distributed and communications are handled under the hood by
MPI for Python (mpi4py). To distribute large arrays we are using a
[new and completely generic algorithm](https://arxiv.org/abs/1804.09536)
that allows for any index set of a multidimensional array to be distributed. We
can distribute just one index (a slab decomposition), two index sets (pencil
decomposition) or even more for higher-dimensional arrays.

mpi4py-fft comes with its own Python interface to the serial
[FFTW](http://www.fftw.org) library. This interface can be used
much like [pyfftw](https://hgomersall.github.io/pyFFTW), and even for
real-to-real transforms, like discrete cosine or sine transforms. Further documentation is found at

[](https://mpi4py-fft.readthedocs.io/en/latest/)

Number of downloads from conda-forge:

[](https://anaconda.org/conda-forge/mpi4py-fft)

## References

{% bibliography --cited %}


# 10 - Ensemble Methods - Continuation

by [Alejandro Correa Bahnsen](albahnsen.com/)

version 0.2, May 2016

## Part of the class [Machine Learning for Security Informatics](https://github.com/albahnsen/ML_SecurityInformatics)

This notebook is licensed under a [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/deed.en_US). Special thanks goes to [Kevin Markham](https://github.com/justmarkham)

Why are we learning about ensembling?

- Very popular method for improving the predictive performance of machine learning models
- Provides a foundation for understanding more sophisticated models

# Part 1: Combination of classifiers - Majority Voting

  The most typical form of an ensemble is made by combining $T$ different base classifiers.
  Each  base classifier $M(\mathcal{S}_j)$ is trained by applying algorithm $M$ to a random subset
  $\mathcal{S}_j$ of the training set $\mathcal{S}$.
  For simplicity we define $M_j \equiv  M(\mathcal{S}_j)$ for $j=1,\dots,T$, and
  $\mathcal{M}=\{M_j\}_{j=1}^{T}$ a set of base classifiers.
  Then, these models are combined using majority voting to create the ensemble $H$ as follows
  $$
    f_{mv}(\mathcal{S},\mathcal{M}) = max_{c \in \{0,1\}} \sum_{j=1}^T
    \mathbf{1}_c(M_j(\mathcal{S})).
  $$

```python
# read in and prepare the chrun data
# Download the dataset
import pandas as pd
import numpy as np

data = pd.read_csv('../datasets/churn.csv')

# Create X and y

# Select only the numeric features
X = data.iloc[:, [1,2,6,7,8,9,10]].astype(np.float)
# Convert bools to floats
X = X.join((data.iloc[:, [4,5]] == 'no').astype(np.float))

y = (data.iloc[:, -1] == 'True.').astype(np.int)
```

```python
X.head()
```

      Account Length
      Area Code
      VMail Message
      Day Mins
      Day Calls
      Day Charge
      Eve Mins
      Int'l Plan
      VMail Plan

      0
      128.0
      415.0
      25.0
      265.1
      110.0
      45.07
      197.4
      1.0
      0.0

      1
      107.0
      415.0
      26.0
      161.6
      123.0
      27.47
      195.5
      1.0
      0.0

      2
      137.0
      415.0
      0.0
      243.4
      114.0
      41.38
      121.2
      1.0
      1.0

      3
      84.0
      408.0
      0.0
      299.4
      71.0
      50.90
      61.9
      0.0
      1.0

      4
      75.0
      415.0
      0.0
      166.7
      113.0
      28.34
      148.3
      0.0
      1.0

```python
y.value_counts().to_frame('count').assign(percentage = lambda x: x/x.sum())
```

      count
      percentage

      0
      2850
      0.855086

      1
      483
      0.144914

```python
from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42)
```

Create 100 decision trees

```python
n_estimators = 100
# set a seed for reproducibility
np.random.seed(123)

n_samples = X_train.shape[0]

# create bootstrap samples (will be used to select rows from the DataFrame)
samples = [np.random.choice(a=n_samples, size=n_samples, replace=True) for _ in range(n_estimators)]
```

```python
from sklearn.tree import DecisionTreeClassifier

np.random.seed(123)
seeds = np.random.randint(1, 10000, size=n_estimators)

trees = {}
for i in range(n_estimators):
    trees[i] = DecisionTreeClassifier(max_features="sqrt", max_depth=None, random_state=seeds[i])
    trees[i].fit(X_train.iloc[samples[i]], y_train.iloc[samples[i]])
```

```python
# Predict
y_pred_df = pd.DataFrame(index=X_test.index, columns=list(range(n_estimators)))
for i in range(n_estimators):
    y_pred_df.ix[:, i] = trees[i].predict(X_test)

y_pred_df.head()
```

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

      438
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0
      ...
      1
      0
      0
      0
      0
      0
      0
      0
      0
      0

      2674
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0
      ...
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0

      1345
      0
      0
      0
      1
      0
      0
      0
      0
      0
      1
      ...
      0
      0
      0
      1
      1
      0
      0
      1
      1
      0

      1957
      0
      0
      0
      0
      0
      0
      0
      0
      0
      1
      ...
      1
      0
      1
      0
      0
      0
      0
      0
      1
      0

      2148
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0
      ...
      0
      0
      0
      0
      0
      1
      0
      0
      1
      0

5 rows × 100 columns

Predict using majority voting

```python
y_pred_df.sum(axis=1)[:10]
```

    438      2
    2674     5
    1345    35
    1957    17
    2148     3
    3106     4
    1786    22
    321      6
    3082    10
    2240     5
    dtype: int64

```python
y_pred = (y_pred_df.sum(axis=1) >= (n_estimators / 2)).astype(np.int)

from sklearn import metrics
metrics.f1_score(y_pred, y_test)
```

    0.52459016393442637

```python
metrics.accuracy_score(y_pred, y_test)
```

    0.89454545454545453

### Using majority voting with sklearn

```python
from sklearn.ensemble import BaggingClassifier
clf = BaggingClassifier(base_estimator=DecisionTreeClassifier(), n_estimators=100, bootstrap=True,
                        random_state=42, n_jobs=-1, oob_score=True)
```

```python
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.53600000000000003, 0.89454545454545453)

# Part 2: Combination of classifiers - Weighted Voting

The majority voting approach gives the same weight to each classfier regardless of the performance of each one. Why not take into account the oob performance of each classifier

First, in the traditional approach, a
similar comparison of the votes of the base classifiers is made, but giving a weight $\alpha_j$
to each classifier $M_j$ during the voting phase
$$
  f_{wv}(\mathcal{S},\mathcal{M}, \alpha)
  =\max_{c \in \{0,1\}} \sum_{j=1}^T \alpha_j \mathbf{1}_c(M_j(\mathcal{S})),
$$
where $\alpha=\{\alpha_j\}_{j=1}^T$.
The calculation of $\alpha_j$ is related to the performance of each classifier $M_j$.
It is usually defined as the normalized misclassification error   $\epsilon$ of the base
classifier $M_j$  in the out of bag set   $\mathcal{S}_j^{oob}=\mathcal{S}-\mathcal{S}_j$
\begin{equation}
  \alpha_j=\frac{1-\epsilon(M_j(\mathcal{S}_j^{oob}))}{\sum_{j_1=1}^T
  1-\epsilon(M_{j_1}(\mathcal{S}_{j_1}^{oob}))}.
\end{equation}

Select each oob sample

```python
samples_oob = []
# show the "out-of-bag" observations for each sample
for sample in samples:
    samples_oob.append(sorted(set(range(n_samples)) - set(sample)))
```

Estimate the oob error of each classifier

```python
errors = np.zeros(n_estimators)

for i in range(n_estimators):
    y_pred_ = trees[i].predict(X_train.iloc[samples_oob[i]])
    errors[i] = 1 - metrics.accuracy_score(y_train.iloc[samples_oob[i]], y_pred_)
```

```python
%matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('fivethirtyeight')

plt.scatter(range(n_estimators), errors)
plt.xlim([0, n_estimators])
plt.title('OOB error of each tree')
```

Estimate $\alpha$

```python
alpha = (1 - errors) / (1 - errors).sum()
```

```python
weighted_sum_1 = ((y_pred_df) * alpha).sum(axis=1)
```

```python
weighted_sum_1.head(20)
```

    438     0.019993
    2674    0.050009
    1345    0.350236
    1957    0.170230
    2148    0.030047
    3106    0.040100
    1786    0.219819
    321     0.059707
    3082    0.100178
    2240    0.050128
    1910    0.180194
    2124    0.190111
    2351    0.049877
    1736    0.950014
    879     0.039378
    785     0.219632
    2684    0.010104
    787     0.710568
    170     0.220390
    1720    0.020166
    dtype: float64

```python
y_pred = (weighted_sum_1 >= 0.5).astype(np.int)

metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.52674897119341557, 0.8954545454545455)

### Using Weighted voting with sklearn

```python
clf = BaggingClassifier(base_estimator=DecisionTreeClassifier(), n_estimators=100, bootstrap=True,
                        random_state=42, n_jobs=-1, oob_score=True)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.53600000000000003, 0.89454545454545453)

```python
errors = np.zeros(clf.n_estimators)
y_pred_all_ = np.zeros((X_test.shape[0], clf.n_estimators))

for i in range(clf.n_estimators):
    oob_sample = ~clf.estimators_samples_[i]
    y_pred_ = clf.estimators_[i].predict(X_train.values[oob_sample])
    errors[i] = metrics.accuracy_score(y_pred_, y_train.values[oob_sample])
    y_pred_all_[:, i] = clf.estimators_[i].predict(X_test)

alpha = (1 - errors) / (1 - errors).sum()
y_pred = (np.sum(y_pred_all_ * alpha, axis=1) >= 0.5).astype(np.int)
```

```python
metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.55335968379446643, 0.89727272727272722)

# Part 3: Combination of classifiers - Stacking

The staking method consists in combining the different base classifiers by learning a
second level algorithm on top of them. In this framework, once the base
classifiers are constructed using the training set  $\mathcal{S}$, a new set is constructed
where the output of the base classifiers  are now considered as the features while keeping the
class labels.

Even though there is no restriction on which algorithm can be used as a second level learner,
it is common to use a linear model, such as
$$
  f_s(\mathcal{S},\mathcal{M},\beta) =
  g \left( \sum_{j=1}^T \beta_j M_j(\mathcal{S}) \right),
$$
where $\beta=\{\beta_j\}_{j=1}^T$, and $g(\cdot)$ is the sign function
$g(z)=sign(z)$ in the case of a linear regression or the sigmoid function, defined
as $g(z)=1/(1+e^{-z})$, in the case of a logistic regression.

Lets first get a new training set consisting of the output of every classifier

```python
X_train_2 = pd.DataFrame(index=X_train.index, columns=list(range(n_estimators)))

for i in range(n_estimators):
    X_train_2[i] = trees[i].predict(X_train)
```

```python
X_train_2.head()
```

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

      2360
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0
      ...
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0

      1412
      0
      0
      1
      0
      0
      0
      0
      0
      0
      0
      ...
      1
      0
      0
      0
      0
      0
      0
      0
      0
      0

      1404
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0
      ...
      0
      0
      0
      0
      1
      0
      0
      0
      0
      0

      626
      1
      1
      0
      1
      1
      1
      1
      1
      1
      1
      ...
      1
      1
      1
      1
      1
      1
      1
      1
      1
      1

      347
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0
      ...
      0
      0
      0
      0
      0
      0
      0
      0
      0
      0

5 rows × 100 columns

```python
from sklearn.linear_model import LogisticRegressionCV
```

```python
lr = LogisticRegressionCV()
lr.fit(X_train_2, y_train)
```

    LogisticRegressionCV(Cs=10, class_weight=None, cv=None, dual=False,
               fit_intercept=True, intercept_scaling=1.0, max_iter=100,
               multi_class='ovr', n_jobs=1, penalty='l2', random_state=None,
               refit=True, scoring=None, solver='lbfgs', tol=0.0001, verbose=0)

```python
lr.coef_
```

    array([[ 0.10093102,  0.1042197 ,  0.09431205,  0.09652843,  0.09709429,
             0.09902616,  0.11100235,  0.09662288,  0.09340919,  0.09112994,
             0.10012606,  0.09821902,  0.09383543,  0.09553507,  0.09147579,
             0.09649564,  0.08965686,  0.09196857,  0.09684012,  0.09020758,
             0.09839592,  0.09513808,  0.1044603 ,  0.10028703,  0.09671603,
             0.09725639,  0.10912207,  0.10590827,  0.10275491,  0.10275279,
             0.10607316,  0.09803225,  0.10319411,  0.0926599 ,  0.09702325,
             0.09524124,  0.088848  ,  0.09960894,  0.09053403,  0.09010282,
             0.0990557 ,  0.0987997 ,  0.10538386,  0.09584352,  0.09633964,
             0.09001206,  0.09181887,  0.08995095,  0.10130986,  0.10827168,
             0.10064992,  0.09771002,  0.08922346,  0.10078438,  0.10173442,
             0.1052274 ,  0.09743252,  0.09597317,  0.08932798,  0.10033609,
             0.10346122,  0.10145004,  0.09017084,  0.10348697,  0.09335995,
             0.09795824,  0.10166729,  0.09306547,  0.09538575,  0.10997592,
             0.09352845,  0.09860336,  0.1059772 ,  0.09583408,  0.09823145,
             0.09995048,  0.10224689,  0.10065135,  0.10208938,  0.11257989,
             0.09956423,  0.11515946,  0.09798322,  0.10092449,  0.10150098,
             0.10275192,  0.09180693,  0.0990442 ,  0.10016612,  0.10145948,
             0.09848122,  0.10322931,  0.09913907,  0.08925477,  0.09950337,
             0.10277594,  0.09249331,  0.0954106 ,  0.1053263 ,  0.09849884]])

```python
y_pred = lr.predict(y_pred_df)
```

```python
metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.53658536585365846, 0.89636363636363636)

### Using sklearn

```python
y_pred_all_ = np.zeros((X_test.shape[0], clf.n_estimators))
X_train_3 = np.zeros((X_train.shape[0], clf.n_estimators))

for i in range(clf.n_estimators):

    X_train_3[:, i] = clf.estimators_[i].predict(X_train)
    y_pred_all_[:, i] = clf.estimators_[i].predict(X_test)

lr = LogisticRegressionCV()
lr.fit(X_train_3, y_train)

y_pred = lr.predict(y_pred_all_)
metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.56250000000000011, 0.89818181818181819)

vs using only one dt

```python
dt = DecisionTreeClassifier()
dt.fit(X_train, y_train)
y_pred = dt.predict(X_test)
metrics.f1_score(y_pred, y_test), metrics.accuracy_score(y_pred, y_test)
```

    (0.44510385756676557, 0.82999999999999996)

# Part 4: Boosting

While boosting is not algorithmically constrained, most boosting algorithms consist of iteratively learning weak classifiers with respect to a distribution and adding them to a final strong classifier. When they are added, they are typically weighted in some way that is usually related to the weak learners' accuracy. After a weak learner is added, the data is reweighted: examples that are misclassified gain weight and examples that are classified correctly lose weight (some boosting algorithms actually decrease the weight of repeatedly misclassified examples, e.g., boost by majority and BrownBoost). Thus, future weak learners focus more on the examples that previous weak learners misclassified. (Wikipedia)

```python
from IPython.display import Image
Image(url= "http://vision.cs.chubu.ac.jp/wp/wp-content/uploads/2013/07/OurMethodv81.png", width=900)
```

## Adaboost

AdaBoost (adaptive boosting) is an ensemble learning algorithm that can be used for classification or regression. Although AdaBoost is more resistant to overfitting than many machine learning algorithms, it is often sensitive to noisy data and outliers.

AdaBoost is called adaptive because it uses multiple iterations to generate a single composite strong learner. AdaBoost creates the strong learner (a classifier that is well-correlated to the true classifier) by iteratively adding weak learners (a classifier that is only slightly correlated to the true classifier). During each round of training, a new weak learner is added to the ensemble and a weighting vector is adjusted to focus on examples that were misclassified in previous rounds. The result is a classifier that has higher accuracy than the weak learners’ classifiers.

Algorithm:

* Initialize all weights ($w_i$) to 1 / n_samples
* Train a classifier $h_t$ using weights
* Estimate training error $e_t$
* set $alpha_t = log\left(\frac{1-e_t}{e_t}\right)$
* Update weights
$$w_i^{t+1} = w_i^{t}e^{\left(\alpha_t \mathbf{I}\left(y_i \ne h_t(x_t)\right)\right)}$$
* Repeat while $e_t<0.5$ and $t<T$

```python
# read in and prepare the chrun data
# Download the dataset
import pandas as pd
import numpy as np

data = pd.read_csv('../datasets/churn.csv')

# Create X and y

# Select only the numeric features
X = data.iloc[:, [1,2,6,7,8,9,10]].astype(np.float)
# Convert bools to floats
X = X.join((data.iloc[:, [4,5]] == 'no').astype(np.float))

y = (data.iloc[:, -1] == 'True.').astype(np.int)

from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42)
n_samples = X_train.shape[0]
```

```python
n_estimators = 10
weights = pd.DataFrame(index=X_train.index, columns=list(range(n_estimators)))
```

```python
t = 0
weights[t] = 1 / n_samples
```

Train the classifier

```python
from sklearn.tree import DecisionTreeClassifier
trees = []
trees.append(DecisionTreeClassifier(max_depth=1))
trees[t].fit(X_train, y_train, sample_weight=weights[t].values)
```

    DecisionTreeClassifier(class_weight=None, criterion='gini', max_depth=1,
                max_features=None, max_leaf_nodes=None, min_samples_leaf=1,
                min_samples_split=2, min_weight_fraction_leaf=0.0,
                presort=False, random_state=None, splitter='best')

Estimate error

```python
y_pred_ = trees[t].predict(X_train)
error = []
error.append(1 - metrics.accuracy_score(y_pred_, y_train))
error[t]
```

    0.13613972234661886

```python
alpha = []
alpha.append(np.log((1 - error[t]) / error[t]))
alpha[t]
```

    1.8477293114995077

Update weights

```python
weights[t + 1] = weights[t]
filter_ = y_pred_ != y_train
```

```python
weights.loc[filter_, t + 1] = weights.loc[filter_, t] * np.exp(alpha[t])
```

Normalize weights

```python
weights[t + 1] = weights[t + 1] / weights[t + 1].sum()
```

**Iteration 2 - n_estimators**

```python
for t in range(1, n_estimators):
    trees.append(DecisionTreeClassifier(max_depth=1))
    trees[t].fit(X_train, y_train, sample_weight=weights[t].values)
    y_pred_ = trees[t].predict(X_train)
    error.append(1 - metrics.accuracy_score(y_pred_, y_train))
    alpha.append(np.log((1 - error[t]) / error[t]))
    weights[t + 1] = weights[t]
    filter_ = y_pred_ != y_train
    weights.loc[filter_, t + 1] = weights.loc[filter_, t] * np.exp(alpha[t])
    weights[t + 1] = weights[t + 1] / weights[t + 1].sum()
```

```python
error
```

    [0.13613972234661886,
     0.15629198387819077,
     0.84370801612180923,
     0.84370801612180923,
     0.84370801612180923,
     0.84370801612180923,
     0.84370801612180923,
     0.84370801612180923,
     0.84370801612180923,
     0.84370801612180923]

### Create classification

Only classifiers when error < 0.5

```python
new_n_estimators = np.sum([x<0.5 for x in error])
```

```python
y_pred_all = np.zeros((X_test.shape[0], new_n_estimators))
for t in range(new_n_estimators):
    y_pred_all[:, t] = trees[t].predict(X_test)
```

```python
y_pred = (np.sum(y_pred_all * alpha[:new_n_estimators], axis=1) >= 1).astype(np.int)
```

```python
metrics.f1_score(y_pred, y_test.values), metrics.accuracy_score(y_pred, y_test.values)
```

    (0.51051051051051044, 0.85181818181818181)

### Using sklearn

```python
from sklearn.ensemble import AdaBoostClassifier
```

```python
clf = AdaBoostClassifier()
clf
```

    AdaBoostClassifier(algorithm='SAMME.R', base_estimator=None,
              learning_rate=1.0, n_estimators=50, random_state=None)

```python
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
metrics.f1_score(y_pred, y_test.values), metrics.accuracy_score(y_pred, y_test.values)
```

    (0.29107981220657275, 0.86272727272727268)

### Gradient Boosting

```python
from sklearn.ensemble import GradientBoostingClassifier

clf = GradientBoostingClassifier()
clf
```

    GradientBoostingClassifier(init=None, learning_rate=0.1, loss='deviance',
                  max_depth=3, max_features=None, max_leaf_nodes=None,
                  min_samples_leaf=1, min_samples_split=2,
                  min_weight_fraction_leaf=0.0, n_estimators=100,
                  presort='auto', random_state=None, subsample=1.0, verbose=0,
                  warm_start=False)

```python
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
metrics.f1_score(y_pred, y_test.values), metrics.accuracy_score(y_pred, y_test.values)
```

    (0.52892561983471076, 0.89636363636363636)


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

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

import os
import glob
```

```python
data_dir = '/Users/sripathisridhar/Desktop/SOL'
```

```python
file_paths= sorted(glob.glob(os.path.join(data_dir, '**', '*.wav')))

file_names= []
for file_path in file_paths:
    file_names.append(os.path.basename(file_path))
```

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

```python
import h5py

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

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

settings = list(ParameterGrid(grid))

for setting in settings:

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

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

```python
batch_str = []
CQT_OCTAVES = 7

features_keys = list(features_dict.keys())

for setting in settings:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

plt.clim(0, 1)

```

### Circle projection

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

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

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

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

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

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

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

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

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

```python
r
```

    123.68845666079883

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

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

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

```python
import sympy

from sympy.geometry import Circle, Point, Line

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

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

xy_prime = []

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

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

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

```

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

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

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

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

```

### Line projection

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

    0.0018336634903286483

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

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

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

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

### Distance matrices

```python
# Projected helix self-distance matrix

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

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

```python
# Isomap embedding self-distance matrix

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

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

```python
# Geodesic self-distance matrix

D_geodesic = isomap.dist_matrix_

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

```python
## Centering matrix

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

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

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

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

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

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

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

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

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

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

    Isomap loss= 117.47799081825059
    Projection loss= 1.0433842670152964
    Total loss= 116.82039800129996

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

    True

```python
## NOT STABLE- REWRITE

helicality_dict[setting['instr']] = [loss_isomap, loss_proj, loss_total]
```

```python

helicality_dict
```

    {'TpC-ord': [14.780170171262531, 6.90431053444174, 13.687580004785238],
     'Hp-ord': [117.47799081825059, 1.0433842670152964, 116.82039800129996],
     'Fl-ord': [10.860110079953524, 7.292949241337281, 9.508129580612602]}

```python
import json

with open("SOL_instr.json", "w") as outfile:
    json.dump(helicality_dict, outfile)
```

```python
infile = open("SOL_instr.json")
helicality_data = json.load(infile)
print(helicality_data)
```

    {'TpC-ord': [14.780170171262531, 6.90431053444174, 13.687580004785238], 'Hp-ord': [117.47799081825059, 1.0433842670152964, 116.82039800129996], 'Fl-ord': [10.860110079953524, 7.292949241337281, 9.508129580612602]}