The Greeks are used as risk measures that represent how sensitive the price of derivatives are to change. This is useful as risks can be treated in isolation and thus allows for tuning in a portfolio to reach a desired level of risk. The values are called 'the Greeks' as they are denoted by Greek letters. Each will be presented in turn as an introduction:

* Delta ($\Delta$) is the first derivative of the spot price $S, \frac{\partial V}{\partial S}$
    * Denotes the rate of change of a portfolio's value

* Gamma ($\Gamma$) is the second derivative of the spot price $S, \frac{\partial^2 V}{\partial S^2}$
    * Rate of change of the Delta

* Theta ($\Theta$) is the first derivative of time $t, \frac{\partial V}{\partial t}$
    * Rate of change of the portfolio's value with respect to time $t$ (Not the maturity time $T$)

* Rho ($\rho$) is the first derivative with respect to the risk-free rate $r, \frac{\partial V}{\partial r}$
    * Denotes the rate of change of the portfolio's value with respect to the risk-free interest rate

* Vega is the first deriative of volatility $\sigma, \frac{\partial V}{\partial \sigma}$
    * Rate of change of value with respect to $\sigma$

### General Derivations to Aid in Greek Computation

Black-Scholes Formula for a Call Option

$$C(S,t) = SN(d_1) - Ke^{-r(T - t)} N(d_2)$$

Probability Density Function of Standard Normal Variable

The standard normal variable will come up often in the derivation of the Greeks, so it is important to note this prior to proceeding. It is denoted by $z$ and is stated as the following:

$$f(x) = \frac{1}{\sqrt{2\pi}} e^{-\frac{x^2}{2}}$$

Derive $S \cdot N'(d_1) = Ke^{-r(T-t)}N'(d_2)$

We know the probability density function as presented above and that:

$$d_1 = \frac{\ln \left(\frac{S}{K} \right) + \left(r + \frac{\sigma^2}{2} \right)(T)}{\sigma \sqrt{T}} + \frac{\sigma \sqrt{T}}{2}$$ $$d_2 = d_1 - \sigma \sqrt{T - t} = \frac{\ln \left(\frac{S}{K} \right) + \left(r - \frac{\sigma^2}{2}\right)(T)}{\sigma \sqrt{T}} - \frac{\sigma \sqrt{T}}{2}$$

Therefore,

$$N'(d_1) = N'(d2 + \sigma \sqrt{T})$$ $$N'(d_1) = \frac{1}{\sqrt{2\pi}} e^{\frac{-d_1^2}{2}}$$ $$N'(d_2) = \frac{1}{\sqrt{2\pi}} e^{\frac{-d_2^2}{2}}$$

To prove the derivation above, insert the equivalent of $N'(d_1)$ and $N'(d_2)$ as shown above.

$$Se^{-\frac{d_1^2}{2}} = Ke^{-r(T-t)} e^{-\frac{d_2^2}{2}}$$

Recall $exp(ln(x)) = x$.

$$S = Ke^{-r(T-t)} \frac{S}{K}e^{r(T-t)}$$

$$S = Ke^{-r(T-t)} exp \left(ln \left(\frac{S}{K} \right) + r(T-t) \right)$$

We can rearrange $d_1$ above to get:

$$ln \left(\frac{S}{K} \right) + r(T-t) = d_1 \sigma \sqrt{T-t} - \frac{\sigma^2 (T-t)}{2}$$

Which then leads us to:

$$S = Ke^{-r(T-t)} exp \left(d_1 \sigma \sqrt{T-t} - \frac{\sigma^2 (T-t)}{2} \right)$$

And knowing $d_2 = d_1 - \sigma \sqrt{T-t}$

$$Se^{-\frac{d_1^2}{2}} = Ke^{-r(T-t)} exp \left(-\frac{d_1^2}{2} + d_1 \sigma \sqrt{T-t} - \frac{\sigma^2(T-t)}{2} \right)$$

$$Se^{-\frac{d_1^2}{2}} = Ke^{-r(T-t)} exp \left(-\frac{\left(d_1 - \sigma \sqrt{T-t} \right)^2}{2} \right)$$

$$Se^{-\frac{d_1^2}{2}} = Ke^{-r(T-t)} e^{-\frac{d_2^2}{2}}$$

Which translates to:

$$SN'(d_1) = Ke^{-r(T-t)} N'(d_2)$$

Taking the derivative with respect to the spot price $S$ of $d_1$ and $d_2$, we see:

$$\frac{\partial d_1}{\partial S} = \frac{\partial d_2}{\partial S} = \frac{1}{\sigma S \sqrt{T-t}}$$

The last two formulas above will be used frequently in the derivation of the Greeks.

## Greeks of Non-Dividend Paying Assets

The implementation of the Greeks will be introduced for both non-dividend and dividend paying assets.

```python
from IPython.display import HTML
```

```python
table = """<table>
<tr>
<th>Greek</th>
<th>Call</th>
<th>Put</th>
</tr>
<tr>
<td>Delta $\Delta$</td>
<td>$N(d_1)$</td>
<td>$-N(-d_1)$</td>
</tr>
<tr>
<td>Theta $\Theta$</td>
<td>$\\frac{\sigma SN'(d_1)}{2\sqrt{T-t}} - rKe^{-r(T-t)} N(d_2)$</td>
<td>$\\frac{-\sigma SN'(d_1)}{2\sqrt{T-t}} + rKe^{-r(T-t)} N(d_2)$</td>
</tr>
<tr>
<td>Gamma $\Gamma$</td>
<td>$\\frac{N'(d_1)}{S\sigma \sqrt{T}}$</td>
<td>$\\frac{N'(d_1)}{S\sigma \sqrt{T}}$</td>
</tr>
<tr>
<td>Vega $v$</td>
<td>$S_0 N'(d_1)\sqrt{T}$</td>
<td>$S_0 N'(d_1)\sqrt{T}$</td>
</tr>
<tr>
<td>Rho $\\rho$</td>
<td>$TKe^{-r(T)} N(d_2)$</td>
<td>$-TKe^{-r(T)} N(-d_2)$</td>
</tr>
</table>"""
```

```python
greeks = HTML(table); greeks
```

Greek
Call
Put

Delta $\Delta$
$N(d_1)$
$-N(-d_1)$

Theta $\Theta$
$\frac{\sigma SN'(d_1)}{2\sqrt{T-t}} - rKe^{-r(T-t)} N(d_2)$
$\frac{-\sigma SN'(d_1)}{2\sqrt{T-t}} + rKe^{-r(T-t)} N(d_2)$

Gamma $\Gamma$
$\frac{N'(d_1)}{S\sigma \sqrt{T}}$
$\frac{N'(d_1)}{S\sigma \sqrt{T}}$

Vega $v$
$S_0 N'(d_1)\sqrt{T}$
$S_0 N'(d_1)\sqrt{T}$

Rho $\rho$
$TKe^{-r(T)} N(d_2)$
$-TKe^{-r(T)} N(-d_2)$

Above is a table of all the derivations of the Greek calls and puts for non-dividend paying assets.

### Python Implementation

```python
import numpy as np
import scipy.stats as si
import sympy as sy
import sympy.statistics as systats
```

#### Delta $\Delta$

```python
def delta_call(S, K, T, r, sigma):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    delta_call = si.norm.cdf(d1, 0.0, 1.0)

    return delta_call
```

```python
def delta_put(S, K, T, r, sigma):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    delta_put = si.norm.cdf(-d1, 0.0, 1.0)

    return -delta_put
```

```python
def delta(S, K, T, r, sigma, option = 'call'):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    if option == 'call':
        result = si.norm.cdf(d1, 0.0, 1.0)
    if option == 'put':
        result = -si.norm.cdf(-d1, 0.0, 1.0)

    return result
```

```python
delta(100, 50, 1, 0.05, 0.25, option = 'put')
```

    -0.00097550992808483267

#### Theta $\Theta$

```python
def theta_call(S, K, T, r, sigma):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = (np.log(S / K) + (r - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    prob_density = 1 / np.sqrt(2 * np.pi) * np.exp(-d1 ** 2 * 0.5)

    theta = (-sigma * S * prob_density) / (2 * np.sqrt(T)) - r * K * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0)

    return theta
```

```python
def theta_put(S, K, T, r, sigma):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = (np.log(S / K) + (r - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    prob_density = 1 / np.sqrt(2 * np.pi) * np.exp(-d1 ** 2 * 0.5)

    theta = (-sigma * S * prob_density) / (2 * np.sqrt(T)) + r * K * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0)

    return theta
```

```python
def theta(S, K, T, r, sigma, option = 'call'):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = (np.log(S / K) + (r - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    prob_density = 1 / np.sqrt(2 * np.pi) * np.exp(-d1 ** 2 * 0.5)

    if option == 'call':
        theta = (-sigma * S * prob_density) / (2 * np.sqrt(T)) - r * K * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0)
    if option == 'put':
        theta = (-sigma * S * prob_density) / (2 * np.sqrt(T)) + r * K * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0)

    return theta
```

```python
theta(110, 100, 2, 0.05, 0.25, option = 'put')
```

    -1.4189854326915405

#### Gamma

```python
def gamma(S, K, T, r, sigma):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    prob_density = 1 / np.sqrt(2 * np.pi) * np.exp(-d1 ** 2 * 0.5)

    gamma = prob_density / (S * sigma * np.sqrt(T))

    return gamma
```

```python
gamma(110, 100, 1, 0.05, 0.25)
```

    0.013760721161229799

#### Vega

```python
def vega(S, S0, K, T, r, sigma):

    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    prob_density = 1 / np.sqrt(2 * np.pi) * np.exp(-d1 ** 2 * 0.5)

    vega = S0 * prob_density * np.sqrt(T)

    return vega
```

```python
vega(110, 105, 100, 1, 0.05, 0.25)
```

    39.734082353051043

#### Rho

```python
def rho_call(S, K, T, r, sigma):

    d2 = (np.log(S / K) + (r - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    rho = T * K * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0)

    return rho
```

```python
def rho_put(S, K, T, r, sigma):

    d2 = (np.log(S / K) + (r - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    rho = -T * K * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0)

    return rho
```

```python
def rho(S, K, T, r, sigma, option = 'call'):

    d2 = (np.log(S / K) + (r - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    if option == 'call':
        rho = T * K * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0)
    if option == 'put':
        rho = -T * K * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0)

    return rho
```

## Greeks of Dividend Paying Assets

```python
table_div = """<table>
<tr>
<th>Greek</th>
<th>Call</th>
<th>Put</th>
</tr>
<tr>
<th>Delta $\Delta$</th>
<th>$e^{-qT}N(d_1)$</th>
<th>$-e^{-qT}N(-d_1)$</th>
</tr>
<tr>
<th>Theta $\Theta$</th>
<th>$-e^{-qT} \\frac{SN(d_1)\sigma}{2\sqrt{T}} - rKe^{-rT}N(d_2) + qSe^{-qT}N(d_1)$</th>
<th>$-e^{-qT} \\frac{SN(d_1)\sigma}{2\sqrt{T}} + rKe^{-rT}N(-d_2) - qSe^{-qT}N(-d_1)$</th>
</tr>
<tr>
<th>Gamma $\Gamma$</th>
<th>$e^{-qT}\\frac{N(d_1)}{S\sigma \sqrt{T}}$</th>
<th>$e^{-qT}\\frac{N(d_1)}{S\sigma \sqrt{T}}$</th>
</tr>
<tr>
<th>Vega $v$</th>
<th>$\\frac{1}{\sqrt{2\pi}} Se^{-q(T-t)} e^{-\\frac{d_1^2}{2}} \sqrt{T-t}$</th>
<th>$\\frac{1}{\sqrt{2\pi}} Se^{-q(T-t)} e^{-\\frac{d_1^2}{2}} \sqrt{T-t}$</th>
</tr>
<tr>
<th>Rho $\\rho$</th>
<th>$KTe^{-rT}N(d_2)$</th>
<th>$-KTe^{-rT}N(-d_2)$</th>
</tr>
</table>
"""
```

```python
greeks_div = HTML(table_div); greeks_div
```

Greek
Call
Put

Delta $\Delta$
$e^{-qT}N(d_1)$
$-e^{-qT}N(-d_1)$

Theta $\Theta$
$-e^{-qT} \frac{SN(d_1)\sigma}{2\sqrt{T}} - rKe^{-rT}N(d_2) + qSe^{-qT}N(d_1)$
$-e^{-qT} \frac{SN(d_1)\sigma}{2\sqrt{T}} + rKe^{-rT}N(-d_2) - qSe^{-qT}N(-d_1)$

Gamma $\Gamma$
$e^{-qT}\frac{N(d_1)}{S\sigma \sqrt{T}}$
$e^{-qT}\frac{N(d_1)}{S\sigma \sqrt{T}}$

Vega $v$
$\frac{1}{\sqrt{2\pi}} Se^{-q(T-t)} e^{-\frac{d_1^2}{2}} \sqrt{T-t}$
$\frac{1}{\sqrt{2\pi}} Se^{-q(T-t)} e^{-\frac{d_1^2}{2}} \sqrt{T-t}$

Rho $\rho$
$KTe^{-rT}N(d_2)$
$-KTe^{-rT}N(-d_2)$

### Python Implementation

#### Delta $\Delta$

```python
def delta_call_div(S, K, T, r, q, sigma):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    delta = np.exp(-q * T) * si.norm.cdf(d1, 0.0, 1.0)

    return delta
```

```python
def delta_put_div(S, K, T, r, q, sigma):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    delta = -np.exp(-q * T) * si.norm.cdf(-d1, 0.0, 1.0)

    return delta
```

```python
def delta_div(S, K, T, r, q, sigma, option = 'call'):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    if option == 'call':
        delta = delta = np.exp(-q * T) * si.norm.cdf(d1, 0.0, 1.0)
    if option == 'put':
        delta = -np.exp(-q * T) * si.norm.cdf(-d1, 0.0, 1.0)

    return delta
```

#### Theta $\Theta$

```python
def theta_call_div(S, K, T, r, q, sigma):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = (np.log(S / K) + (r - q - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    theta = -np.exp(-q * T) * (S * si.norm.cdf(d1, 0.0, 1.0) * sigma) / (2 * np.sqrt(T)) - r * K * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0) + q * S * np.exp(-q * T) * si.norm.cdf(d1, 0.0, 1.0)

    return theta
```

```python
def theta_put_div(S, K, T, r, q, sigma):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = (np.log(S / K) + (r - q - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    theta = -np.exp(-q * T) * (S * si.norm.cdf(d1, 0.0, 1.0) * sigma) / (2 * np.sqrt(T)) + r * K * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0) - q * S * np.exp(-q * T) * si.norm.cdf(-d1, 0.0, 1.0)

    return theta
```

```python
def theta_div(S, K, T, r, q, sigma, option = 'call'):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = (np.log(S / K) + (r - q - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    if option == 'call':
        theta = -np.exp(-q * T) * (S * si.norm.cdf(d1, 0.0, 1.0) * sigma) / (2 * np.sqrt(T)) - r * K * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0) + q * S * np.exp(-q * T) * si.norm.cdf(d1, 0.0, 1.0)
    if option == 'put':
        theta = -np.exp(-q * T) * (S * si.norm.cdf(d1, 0.0, 1.0) * sigma) / (2 * np.sqrt(T)) + r * K * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0) - q * S * np.exp(-q * T) * si.norm.cdf(-d1, 0.0, 1.0)

    return theta
```

#### Gamma $\Gamma$

```python
def gamma_div(S, K, T, r, q, sigma):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    gamma = np.exp(-q * T) * si.norm.cdf(d1, 0.0, 1.0) / S * sigma * np.sqrt(T)

    return gamma
```

#### Vega $v$

```python
def vega_div(S, K, T, r, q, sigma):

    d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    vega = 1 / np.sqrt(2 * np.pi) * S * np.exp(-q * T) * np.exp(-d1 ** 2 * 0.5) * np.sqrt(T)

    return vega
```

#### Rho $\rho$

```python
def rho_call_div(S, K, T, r, q, sigma):

    d2 = (np.log(S / K) + (r - q - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    rho = K * T * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0)

    return rho
```

```python
def rho_put_div(S, K, T, r, q, sigma):

    d2 = (np.log(S / K) + (r - q - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    rho = -K * T * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0)

    return rho
```

```python
def rho_div(S, K, T, r, q, sigma, option = 'call'):

    d2 = (np.log(S / K) + (r - q - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))

    if option == 'call':
        rho = K * T * np.exp(-r * T) * si.norm.cdf(d2, 0.0, 1.0)
    if option == 'put':
        rho = -K * T * np.exp(-r * T) * si.norm.cdf(-d2, 0.0, 1.0)

    return rho
```

## References

[Sherbin, A. (2015). How to price and trade options: identify, analyze, and execute the best trade probabilities.
    Hoboken, NJ: John Wiley & Sons, Inc.](https://amzn.to/37ajBnM)

[Ursone, P. (2015). How to calculate options prices and their Greeks: exploring the Black Scholes model from Delta
    to Vega. Chichester: Wiley.](https://amzn.to/2UzXDrD)


```python
%%HTML
<!-- Mejorar visualización en proyector -->
<style>
.rendered_html {font-size: 1.2em; line-height: 150%;}
div.prompt {min-width: 0ex; padding: 0px;}
.container {width:95% !important;}
</style>
```

```python
%autosave 0
%matplotlib notebook
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import display
import ipywidgets as widgets
from matplotlib import animation
from functools import partial
slider_layout = widgets.Layout(width='600px', height='20px')
slider_style = {'description_width': 'initial'}
IntSlider_nice = partial(widgets.IntSlider, style=slider_style, layout=slider_layout, continuous_update=False)
FloatSlider_nice = partial(widgets.FloatSlider, style=slider_style, layout=slider_layout, continuous_update=False)
SelSlider_nice = partial(widgets.SelectionSlider, style=slider_style, layout=slider_layout, continuous_update=False)
```

# Aprendizaje Supervisado

Esquema donde se busca aprender un mapeo o función

$$
f_{\theta}: \mathcal{X} \rightarrow \mathcal{Y},
$$

donde $\mathcal{X}$ es el dominio de nuestros datos (entrada) e $\mathcal{Y}$ es un objetivo (salida)

Entrenamos nuestro modelo a partir de un conjunto de $N$ ejemplos:

$$
\{(x_1, y_1), (x_2, y_2), \ldots, (x_N, y_N)\},
$$

donde cada ejemplo es una tupla formada de datos $x_i \in \mathcal{X}$ y objetivo $y_i \in \mathcal{Y}$

Si la variable objetivo es
- continua: hablamos de un problema de regresión o aproximación de funciones
- categórica: hablamos de un problema de clasificación

La naturaleza de los datos  depende del problema

Lo más común es que los datos $x_i$ se estructuren como arreglos de $M$ componentes

A los componentes los llamamos atributos o *features*

### Aprendizaje

El vector $\theta$ corresponde a los **parámetros** del modelo

> Aprender o ajustar el modelo corresponde a encontrar el valor "óptimo" de $\theta$

Usamos una **función de pérdida/costo** $L(\theta)$ para medir el error de nuestro modelo

> **Minimizamos** la función de costo para encontrar el mejor $\theta$

Cuando hablamos de $\theta$ "óptimo" lo decimos en el sentido de una función de costo particular

### Optimización

Queremos resolver el siguiente problema
$$
\min_\theta L(\theta)
$$

Una opción es evaluar $L()$ en todo el espacio de posibles $\theta$: Fuerza Bruta

Pero en general esto no es computacionalmente posible

Podemos usar **técnicas de optimización** para encontrar el mejor $\theta$

Si $L(\theta)$ es continua y derivable podemos escribir

$$
\nabla_\theta L(\theta) = \vec 0
$$

e intentar despejar $\theta~$

## Regresión lineal

Modelo para aprender una mapeo entre una o más variables continuas (atributos) hacia una variable continua (objetivo)

En un esquema con $M$ atributos y $N$ ejemplos tenemos

$$
y_i = f_\theta(\vec x_i) = \vec w^T \vec x_i + b = \sum_{j=1}^M w_j x_{ij} + b,
$$

donde $f_\theta$ es un modelo parámetrico (hiperplano) con $M+1$ parámetros

$$
\theta= \begin{pmatrix} b \\ w_1 \\ w_2 \\ \vdots \\ w_M \end{pmatrix}
$$

También podemos escribir el sistema matricialmente como

$$
Y = X \theta
$$

donde $Y= \begin{pmatrix} y_1 \\ y_2 \\ \vdots \\y_N\end{pmatrix} \in \mathbb{R}^N$, $X = \begin{pmatrix} 1 & x_{11} & x_{12}& \ldots& x_{1M} \\ \vdots & \vdots & \vdots& \ddots& \vdots \\ 1 & x_{N1} & x_{N2}& \ldots& x_{NM} \\ \end{pmatrix} \in \mathbb{R}^{N\times M}$ y $\theta \in \mathbb{R}^M$

Una función de costo razonable es

$$
L(\theta) = \frac{1}{2} (Y - X\theta)^T (Y - X\theta)
$$

que corresponde al cuadrado de los errores

Luego si derivamos e igualamos a cero obtenemos

$$
\nabla_\theta L(\theta) = -X^T(Y-X\theta) = 0,
$$

y despejando

$$
\hat \theta = (X^T X)^{-1} X^T Y
$$

siempre y cuando podamos invertir $X^T X$

> Esto se conoce como solución de mínimos cuadrados

#### Funciones base

Podemos generalizar el regresor lineal aplicando transformaciones a $X$

Por ejemplo una regresión polinomial de grado $M$ sería

$$
y_i = f_\theta(x_i) = \sum_{j=1}^M w_j x_{i}^j + b,
$$

y su solución sería

$$
\hat \theta = (\Phi^T \Phi)^{-1} \Phi^T Y,
$$

donde $\Phi = \begin{pmatrix} 1 & x_1 & x_1^2& \ldots& x_1^M \\ \vdots & \vdots & \vdots& \ddots& \vdots \\ 1 & x_N & x_N^2& \ldots& x_N^M \\ \end{pmatrix}$

```python
plt.close('all'); fig, ax = plt.subplots(figsize=(6, 4), tight_layout=True)
poly_basis = lambda x,N : np.vstack([x**k for k in range(N)]).T
theta = [10, -2, -0.3, 0.1]
x = np.linspace(-5, 6, num=21);
X = poly_basis(x, len(theta))
y = np.dot(X, theta)

rseed, sigma = 0, 1.
np.random.seed(rseed);
Y = y + sigma*np.random.randn(len(x))
P = np.random.permutation(len(x))
train_idx, valid_idx = P[:len(x)//2], P[len(x)//2:]

def update_plot(ax, N):
    ax.cla();
    Phi = poly_basis(x, N)
    theta_hat = np.linalg.lstsq(Phi[train_idx, :], Y[train_idx], rcond=None)[0]
    ax.plot(x, y, 'g-', linewidth=2, label='Implícito', alpha=0.6, zorder=-100)
    ax.scatter(x[train_idx], Y[train_idx], s=50, label='Entrenamiento')
    ax.scatter(x[valid_idx], Y[valid_idx], s=50, label='Validación')
    ax.vlines(x[train_idx], np.dot(Phi[train_idx, :], theta_hat), Y[train_idx])
    ax.vlines(x[valid_idx], np.dot(Phi[valid_idx, :], theta_hat), Y[valid_idx])
    x_plot = np.linspace(-5, 6, num=100);
    ax.plot(x_plot, np.dot(poly_basis(x_plot, N), theta_hat), 'k-', linewidth=2, label='Modelo')
    ax.set_ylim([-5, 15]); plt.legend()

widgets.interact(update_plot, ax=widgets.fixed(ax), N=IntSlider_nice(min=1, max=11));
```

# Complejidad y Sobreajuste

En el ejemplo anterior vimos que se puede obtener modelos más flexibles si aumentamos el grado del polinomio

> Aumentar la cantidad de parámetros (grados de libertad) hace al modelo más flexible y más complejo

Si la flexibilidad es excesiva aproximamos los datos con cero error

Esto no es bueno ya que estamos aprendiendo "de memoria" los datos y ajustandonos al ruido

> Sobreajuste: Aprender perfectamente los datos usados para entrenar

El modelo sobreajustado predice muy mal los datos "que no ha visto"

> El sobreajuste es inversamente proporcional a la capacidad de generalización

Por esta razón usamos conjuntos de validación

Figura: https://www.d2l.ai/chapter_multilayer-perceptrons/underfit-overfit.html

# Representatividad y Validación

El primer paso para entrenar nuestro modelo es obtener datos (duh)

Es crítico que los datos que utilizemos **representen** adecuadamente el problema que queremos resolver

Sea un espacio de datos (circulo negro) y muestras (puntos azules), ¿que puede decir de los siguientes casos?

```python
from matplotlib.patches import Circle
fig, ax = plt.subplots(1, 2, figsize=(7, 3))
np.random.seed(19)
for ax_ in ax:
    ax_.axis('off')
    ax_.set_xlim([-3, 3])
    ax_.set_ylim([-3, 3])
r = 2.5*np.random.rand(100); t = np.random.rand(100);
ax[0].scatter(r*np.cos(2.0*np.pi*t), r*np.sin(2.0*np.pi*t))
p = Circle((0, 0), 3, fill=False, ec='k')
ax[0].add_artist(p)
r = 2.5*np.random.rand(100); t = np.random.rand(100);
ax[1].scatter(r*np.cos(t), r*np.sin(t))
p = Circle((0, 0), 3, fill=False, ec='k')
ax[1].add_artist(p);
```

Siempre que podamos controlar el proceso de muestreo debemos poner atención a evitar **sesgos**

Asumiendo que nuestro dataset es representativo el siguiente paso es **entrenar**

Para combatir el sobreajuste podemos usar **estrategias de validación**

Consisten en separar el conjunto en dos o más subconjuntos
- Holdout: Entrenamiento/Validación/Prueba
- K-fold cross-validation y Leave one-out (N-fold) cross-validation
- Versiones estratificadas/balanceadas de las anteriores

Para que nuestro conjuntos de entrenamiento y validación sigan siendo representativos del total los **seleccionamos aleatoriamente**

Medimos $L(\theta)$ en entrenamiento y validación

> Optimizamos nuestro modelo minimizando el error de entrenamiento

> Seleccionamos los parámetros e hiper-parámetros que dan mínimo error de validación

> Comparamos distintas familias de modelos  con el error de prueba

En el ejemplo anterior:

```python
fig, ax = plt.subplots(figsize=(6, 4), tight_layout=True)
N_values = np.arange(1, 11)
mse = np.zeros(shape=(len(N_values), 2))
for i, N in enumerate(N_values):
    Phi = poly_basis(x, N)
    theta_hat = np.linalg.lstsq(Phi[train_idx, :], Y[train_idx], rcond=None)[0]
    mse[i, 0] = np.mean(np.power(Y[train_idx] - np.dot(Phi[train_idx, :], theta_hat), 2))
    mse[i, 1] = np.mean(np.power(Y[valid_idx] - np.dot(Phi[valid_idx, :], theta_hat), 2))
ax.plot(N_values, mse[:, 0], label='Entrenamiento')
ax.plot(N_values, mse[:, 1], label='Validación')
idx_best = np.argmin(mse[:, 1])
ax.scatter(N_values[idx_best], mse[idx_best, 1], c='k', s=100)
plt.legend()
ax.set_ylim([1e-4, 1e+4])
ax.set_yscale('log')
ax.set_xlabel('Grado del polinomio')
ax.set_ylabel('Loss');
```

En resumen

- Bajo error de entrenamiento y de validación: **Ideal**

- Bajo error de entrenamiento y alto error de validación: **Modelo sobreajustado**

- Alto error de entrenamiento y de validación: Considera otro modelo y/o revisa tu código

## Neurona artificial o regresor logístico

Modelo para aprender una mapeo entre una o más variables continuas (atributos) hacia una variable binaria (objetivo)

$$
y_i \leftarrow f_\theta(\vec x_i) = \mathcal{S} \left(\theta_0 + \sum_{j=1}^M \theta_j x_{ij}\right)
$$

donde $\mathcal{S}(z) = \frac{1}{1+\exp(-z)} \in [0, 1]$ se conoce como función logística o sigmoide

> Modelo de clasificación binaria (dos clases)

Podemos interpretar la salida del clasificador como una probabilidad

```python
data = np.concatenate((np.random.randn(50, 2), 1.5 + np.random.randn(50, 2)), axis=0)
label = np.array([0]*50 + [1]*50)
fig, ax = plt.subplots(1, figsize=(9, 4))
from matplotlib import cm
fig.colorbar(cm.ScalarMappable(cmap=plt.cm.RdBu_r), ax=ax)
x_min, x_max = data[:, 0].min() - 0.5, data[:, 0].max() + 0.5
y_min, y_max = data[:, 1].min() - 0.5, data[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.05), np.arange(y_min, y_max, 0.05))

def sigmoid(X, w, b):
    Z = np.dot(X, w) + b
    return 1./(1 + np.exp(-Z))

def update_plot(w1, w2, b):
    ax.cla()
    ax.scatter(data[:50, 0], data[:50, 1], c='k', s=20)
    ax.scatter(data[50:, 0], data[50:, 1], c='k', s=20, marker='x')
    ax.contourf(xx, yy, sigmoid(np.c_[xx.ravel(), yy.ravel()], np.array([w1, w2]), b).reshape(xx.shape),
                cmap=plt.cm.RdBu_r, alpha=0.75)

widgets.interact(update_plot,
                 w1=FloatSlider_nice(min=-10, max=10),
                 w2=FloatSlider_nice(min=-10, max=10),
                 b=FloatSlider_nice(min=-10, max=10));
```

¿Qué función de costo es apropiada en este caso?

Tipicamente se usa la **Entropía Cruzada Binaria**

$$
L(\theta) = \sum_{i=1}^N  -y_i \log( f_\theta(\vec x_i) ) - (1-y_i) \log(1 - f_\theta(\vec x_i))
$$

¿Por qué?

Calculemos su gradiente
$$
\begin{align}
\frac{d}{d \theta_j} L(\theta) &= \sum_{i=1}^N  \left(-\frac{y_i}{f_\theta(\vec x_i)} + \frac{1-y_i}{1 - f_\theta(\vec x_i)}\right) \frac{f_\theta(\vec x_i)}{d\theta_j} \nonumber \\
&= (y_i - f_\theta(\vec x_i)) x_{ij}
\end{align}
$$

Por la no linealidad $f_\theta(\vec x_i)$ ya no podemos despejar analiticamente $\theta$

> Podemos usar métodos de optimización iterativos

## Optimización: Método de Newton

Sea el valor actual del vector de parámetros $\theta_t$

Queremos encontrar el mejor "próximo valor" según nuestra función objetivo
$$
\theta_{t+1} = \theta_t + \Delta \theta
$$
Consideremos la aproximación de Taylor de segundo orden de $f$
$$
f(\theta_{t} + \Delta \theta) \approx f(\theta_t) + \nabla f (\theta_t) \Delta \theta + \frac{1}{2} \Delta \theta^T H_f (\theta_t) \Delta \theta
$$
Derivando en función de $\Delta \theta$ e igualando a cero tenemos
$$
\begin{align}
\nabla f (\theta_t)  +  H_f (\theta_t) \Delta \theta &= 0 \nonumber \\
\Delta \theta &= - [H_f (\theta_t)]^{-1}\nabla f (\theta_t)  \nonumber \\
\theta_{t+1} &= \theta_{t} - [H_f (\theta_t)]^{-1}\nabla f (\theta_t)  \nonumber \\
\end{align}
$$

- Se obtiene una regla iterativa en función del **Gradiente** y del **Hessiano**
- La solución depende de $\theta_0$
- "Asumimos" que la aproximación de segundo orden es "buena"
- Si nuestro modelo tiene $N$ parámetros el Hessiano es de $N\times N$, ¿Qué pasa si $N$ es grande?

## Optimización: Gradiente descendente

Si el Hessiano es prohibitivo podemos usar una aproximación de primer orden

El método más clásico es el **gradiente descendente**
$$
\theta_{t+1} = \theta_{t} - \eta \nabla f (\theta_t)
$$

donde hemos reemplazado el Hessiano por una constante $\eta$ llamado "paso" o "tasa de aprendizaje"

- ¿Cómo cambia la optimización con distintos $\eta$?
- ¿Qué ocurre cuando la superficie de error tiene mínimos locales?

```python
plt.close('all'); fig, ax = plt.subplots(2, figsize=(7, 4), tight_layout=True, sharex=True)
x = np.linspace(-4, 6, num=100)
f = lambda theta : 5+ (theta-1.)**2 #+ 10*np.sin(theta)
df = lambda theta : 2*(theta -1.) #+ 10*np.cos(theta)
df2 = lambda theta : 2 #- 10*np.cos(theta)

t = 10*np.random.rand(10) - 4.
ax[0].plot(x, f(x))
sc = ax[0].scatter(t, f(t), s=100)

ax[1].set_xlabel(r'$\theta$')
ax[0].set_ylabel(r'$f(\theta)$')
ax[1].plot(x, -df(x))
ax[1].set_ylabel(r'$-\nabla f(\theta)$')
eta = 0.01

def update(n):
    t = sc.get_offsets()[:, 0]
    t -= eta*df(t)
    #t -= df(t)/(df2(t)+10)
    sc.set_offsets(np.c_[t, f(t)])

anim = animation.FuncAnimation(fig, update, frames=100, interval=200, repeat=False, blit=True)
```

# Neurona artificial en [PyTorch](https://pytorch.org/)

```python
import torch
from torch.utils.data import DataLoader, TensorDataset, Subset

torch_set = TensorDataset(torch.from_numpy(data.astype('float32')),
                          torch.from_numpy(label.astype('float32')))

import sklearn.model_selection
train_idx, test_idx = next(sklearn.model_selection.ShuffleSplit(train_size=0.6).split(data, label))
torch_train_loader = DataLoader(Subset(torch_set, train_idx), shuffle=True, batch_size=16)
torch_valid_loader = DataLoader(Subset(torch_set, test_idx), shuffle=False, batch_size=256)
```

```python
class Neurona(torch.nn.Module):

    def __init__(self):

        super(Neurona, self).__init__()

        self.fc = torch.nn.Linear(in_features=2,  out_features=1, bias=True)
        self.activation = torch.nn.Sigmoid()

    def forward(self, x):
        return self.activation(self.fc(x))
```

```python
fig, ax = plt.subplots(1, 2, figsize=(8, 3.5), tight_layout=True)

net = Neurona()
n_epochs = 1000
optimizer = torch.optim.SGD(net.parameters(), lr=1e-2)
criterion = torch.nn.BCELoss(reduction='sum')
running_loss = np.zeros(shape=(n_epochs, 2))

def train_one_epoch(net):
    train_loss, valid_loss = 0.0, 0.0
    for sample_data, sample_label in torch_train_loader:
        output = net(sample_data)
        optimizer.zero_grad()
        loss = criterion(output, sample_label)
        train_loss += loss.item()
        loss.backward()
        optimizer.step()
    for sample_data, sample_label in torch_valid_loader:
        output = net(sample_data)
        loss = criterion(output, sample_label)
        valid_loss += loss.item()
    return train_loss/torch_train_loader.dataset.__len__(), valid_loss/torch_valid_loader.dataset.__len__()

def update_plot(k):
    global net, running_loss
    [ax_.cla() for ax_ in ax]
    running_loss[k, 0], running_loss[k, 1] = train_one_epoch(net)
    Z = net.forward(torch.from_numpy(np.c_[xx.ravel(), yy.ravel()].astype('float32')))
    Z = Z.detach().numpy().reshape(xx.shape)
    ax[0].contourf(xx, yy, Z, cmap=plt.cm.RdBu_r, alpha=1., vmin=0, vmax=1)
    for i, (marker, name) in enumerate(zip(['o', 'x'], ['Train', 'Test'])):
        ax[0].scatter(data[label==i, 0], data[label==i, 1], color='k', s=10, marker=marker, alpha=0.5)
        ax[1].plot(np.arange(0, k+1, step=1), running_loss[:k+1, i], '-', label=name+" cost")
    plt.legend(); ax[1].grid()

#update_plot(0)
anim = animation.FuncAnimation(fig, update_plot, frames=n_epochs,
                               interval=10, repeat=False, blit=False)
```

# Métricas: Evaluando un clasificador binario

La salida de este clasificador es un valor en el rango $[0, 1]$

Para tomar un decisión binaria se debe seleccionar un umbral $\mathcal{T}$ tal que

$$
d_i =
\begin{cases}
0, & \text{si } f_\theta(\vec x_i)  < \mathcal{T} \\
1, & \text{si } f_\theta(\vec x_i) \geq \mathcal{T}
\end{cases}
$$

Una vez seleccionado el umbral se puede contar la cantidad de
- **True positives** (TP): Era clase (1) y lo clasifico como (1)
- **True negative** (TN): Era clase (0) y lo clasifico como (0)
- **False positives** (FP): Era clase (0) y lo clasifico como (1): Error tipo I
- **False negative** (FN): Era clase (1) y lo clasifico como (0): Error tipo II

A partir de estas métricas se construye la **tabla de confusión** del clasificador

|Clasificado como/En realidad era|Positivo|Negativo|
|---|---|---|
|Positivo:|TP | FP |
|Negativo:| FN | TN |

En base a estas métricas se construyen otras
$$
\text{Recall} = \frac{TP}{TP + FN}
$$
también conocida como la **Tasa de verdaderos positivos** (TPR) o sensitividad

> TPR: La proporción de positivos correctamente clasificados respecto al total de positivos

$$
\text{FPR} = \frac{FP}{TN + FP} = 1 - \frac{TN}{TN + FP}
$$

la **tasa de falsos positivos** (FPR) también representada como "1 - especificidad"

> FPR: La proporción de negativos incorrectamente clasificados respecto al total de negativos

$$
\text{Precision} = \frac{TP}{TP + FP}
$$

también conocido como pureza

> Precision: La proporción de positivos correctamente clasificados respecto a todos los ejemplos clasificados como positivo

$$
\text{Accuracy} = \frac{TP+TN}{TP + FP + FN+ TN}
$$

> Accuracy: La proporción de ejemplos correctamente clasificados

$$
\text{f1-score} = \frac{2*\text{Recall}*\text{Precision}}{\text{Recall} + \text{Precision}}
$$

> f1-score: Media armónica entre Recall y Precision asumiendo igual ponderación

Si las clases son desbalanceadas entonces f1-score es más aconsejable que accuracy

```python
probability = net(torch_set.tensors[0]).detach().numpy()
import sklearn.metrics

print("Matriz de confusión:")
print(sklearn.metrics.confusion_matrix(y_true=torch_set.tensors[1].numpy().astype(int),
                                       y_pred=probability[:, 0] > 0.5))

print(sklearn.metrics.classification_report(y_true=torch_set.tensors[1].numpy().astype(int),
                                      y_pred=probability[:, 0] > 0.5))
```

Notar que a distintos umbrales $\mathcal{T}$ se obtienen distintas tablas de confusión

Se midemos estas métricas usando distintos umbrales podemos construir una curva de desempeño

Tipicamente se usan
- Curva ROC: TPR vs FPR
- Curva Precision vs Recall

```python
fpr, tpr, th = sklearn.metrics.roc_curve(y_true=torch_set.tensors[1].numpy().astype(int),
                          y_score=probability[:, 0])

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(fpr, tpr);
ax.set_xlabel('Tasa de Falsos positivos')
ax.set_ylabel('Tasa de Verdaderos Positivos (Recall)')
ax.set_title('AUC: %f' %sklearn.metrics.auc(fpr, tpr))
```

```python
prec, rec, th = sklearn.metrics.precision_recall_curve(y_true=torch_set.tensors[1].numpy().astype(int),
                                                       probas_pred=probability[:, 0])

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(rec, prec, '-');
ax.set_xlabel('Recall')
ax.set_ylabel('Precision')
```

# Ojo con:

#### La distribución de los datos donde se aplicará el clasificador es distinta a la que usaste para entrenar/validar

Actualiza tus conjuntos de datos para que sean representativos!

#### Usa los subconjuntos adecuadamente

Ajusta los parámetros con el set de validación

Compara distintas familias de modelos con el set de prueba

#### La métrica que usas no es la adecuada para el problema

Si el problema tiene clases desbalanceadas el *accuracy* puede ser muy alto, contrasta usando métricas sencibles al desbalance (e.g. *f1-score*)

#### Mi modelo se sobreajusta de inmediato

Prueba disminuyendo la complejidad/arquitectura del modelo o añadiendo **regularización**

Esto también puede ser señal de que necesitas más ejemplos para entrenar

Se pueden usar **Técnicas de aumentación** de datos

#### Mi modelo no está aprendiendo

Si estas seguro que no hay bugs prueba aumentando la complejidad del modelo

#### Estudia los errores de tu modelo para mejorarlo

Analiza los datos mal clasificados y busca patrones

Revisa que las etiquetas estén correctas

Revisa que los atributos estén adecuadamente calculados

Propon nuevos atributos que ayuden a clasificador los ejemplos difíciles

```python

```


## Medical Diagnosis with Costs

In this exercise, we continue off our running medical diagnosis example, now looking at a decision variant of it where we need to decide whether to treat a patient, and there are associated costs.

We will be using both conditional expectation and the expectation of the function of a random variable. In fact, we need to combine the two! For random variables $X$ and $Y$ for which we know (or have computed) the conditional distribution $p_{X\mid Y}(\cdot \mid y)$, and for any function f such that $f(x)$ is a real number for every $x$ in the alphabet of $X$, then

$$\mathbb {E}[f(X)\mid Y=y] = \sum _ x f(x) p_{X\mid Y}(x\mid y).$$

(See if you can figure out why this is true from our earlier coverage of expectation!)

We previously computed the probability that someone has a infection given that a test for the infection turned out positive. But how do we translate this into a decision of whether or not we should treat the person? Ideally, we should always choose to treat people who, in truth, have the infection and choose “don't treat" for people without the infection. However, it is possible that we may treat actually healthy people, a false positive that leads to unnecessary medical costs. As well, we may fail to treat people with the infection, a false negative that could require more expensive treatment later on.

We can encode these costs into the following “cost table" (or cost function) $C(\cdot , \cdot )$, where $C(x,d)$ is the cost when the patient's true infection state is $x$ (“healthy" or “infected"), and we make decision $d$ (“treat" or “don't treat"). For our problem, here is the cost function:

| Truth $x$ | Decision $d$ | $C(x,d)$ |
|-----------|--------------|---------:|
| healthy   | don't treat  |      \$0 |
| healthy   | treat        |  \$20000 |
| infected  | don't treat  |  \$50000 |
| infected  | treat        |  \$20000 |

This cost table corresponds to a situation where the treatment costs \$20000, regardless of whether the patient's true state is healthy or infected. If the “don't treat" choice is made, then there are two potential costs: \$0 if the patient is indeed healthy, or \$50000 if the patient actually has the infection, perhaps corresponding to a more expensive future medical procedure needed if the infection is not treated now.

Recall the medical diagnosis setup from before, summarized in these tables:

| $X$      | Probability | $p_{Y\mid X}$ | healthy | infected |
|----------|-------------|---------------|---------|----------|
| healthy  | $0.999$     | positive      | $0.01$  | $0.99$   |
| infected | $0.001$     | negative      | $0.99$  | $0.01$   |

Let's suppose we've observed $Y=\text {positive}$. To use the cost table to help us make a decision, note that there are two possible realizations of the world given that $Y=\text {positive}$:

- First, it could be that $X=\text{infected}$ given $Y=\text {positive}$. This realization has probability $p_{X\mid Y}(\text {infected}\mid \text {positive})$. Under this realization of the world, by looking at the last two rows in the above cost table:

| Decision $d$|    Cost |
|-------------|--------:|
| treat       | \$20000 |
| don't treat | \$50000 |

- Second, it could be that $X=\text {healthy}$ given $Y=\text {positive}$. This realization has probability $p_{X\mid Y}(\text {healthy}\mid \text {positive})=1-p_{X\mid Y}(\text {infected}\mid \text {positive})$. Under this realization of the world, by looking at the two first rows in the cost table:

| Decision $d$ |    Cost |
|--------------|--------:|
| treat        | \$20000 |
| don't treat  |     \$0 |

Thus, putting together the pieces:

If we decide $d=\text{don't treat}$, then the cost is a random variable and is given by the probability table $p_{C(X, \text {don't treat}) \mid Y}(\cdot \mid \text {positive})$ below:

| Probability                              |    Cost |
|------------------------------------------|--------:|
| $p_{X\mid Y}$(infected $\mid$ positive)  | \$50000 |
| $p_{X\mid Y}$(healthy $\mid$ positive)   |     \$0 |

**Question:** What is $\mathbb {E}[C(X, \text {don't treat}) \mid Y = \text {positive}]$?

Express your answer as a function of $a \triangleq p_{X\mid Y}(\text {infected}\mid \text {positive})$ (unless a does not appear in the answer). Do not plug in the numerical value for a that you determined in a previous exercise, and also do not put a dollar sign in your answer.

**Answer:** The conditional expectection will be

$$\begin{align}
\mathbb {E}[C(X, \text {don't treat}) \mid Y = \text {positive}]
=& C(\text{healthy, don't treat})  \times p_{X\mid Y}(\text {healthy}\mid \text {positive}) \\
 & + C(\text{infected, don't treat}) \times p_{X\mid Y}(\text {infected}\mid \text {positive})\\
=&0\times (1-a) + 50000\times a \\
=&50000 a
\end{align}$$

If we decide $d=\text {treat}$, then the cost is a random variable and is given by the probability table $p_{C(X, \text {treat}) \mid Y}(\cdot \mid \text {positive})$.

**Question:** What is $\mathbb {E}[C(X, \text {treat}) \mid Y = \text {positive}]$? (Hint: Figure out what the probability table $p_{C(X, \text {treat}) \mid Y}(\cdot \mid \text {positive})$ is first. See if you can figure out how we obtained the probability table $p_{C(X, \text {don't treat}) \mid Y}(\cdot \mid \text {positive})$ in the previous part.)

Express your answer as a function of $a \triangleq p_{X\mid Y}(\text {infected}\mid \text {positive})$ (unless a does not appear in the answer). Do not plug in the numerical value for a that you determined in a previous exercise. Express your answer as a function of $a$, where $a \triangleq p_{X\mid Y}(\text {infected}\mid \text {positive})$.

**Answer:** If we treat we have to pay \$20000 in both cases. Hence 20000 is the expectation.

Given that $Y=\text {positive}$, to choose between which of the two decisions $d=\text {treat}$ and $d=\text {don't treat}$ to make, we can choose whichever decision $d$ that has the lower expected cost $\mathbb {E}[C(X,d)\mid Y=\text {positive}]$ (note that it could be that the expected costs are the same, in which case we could just break the tie arbitrarily).

Look at your answers to the previous two parts. You should notice that “don't treat" has the lowest possible cost when a is at most some threshold $t$, which you will now determine.

Phrased in terms of an equation, with variable a defined as before $a \triangleq p_{X\mid Y}(\text {infected}\mid \text {positive})$, there is a threshold $t$ for which

$$\begin{eqnarray}
\arg\min_{d \in \{\text{treat}, \text{don't treat}\}}
  \mathbb{E}[C(X, d) \mid Y = \text{positive}]
&=&
\begin{cases}
\text{don't treat} & \text{if } a \le t, \\
\text{treat} & \text{if } a > t,
\end{cases}
\end{eqnarray}$$

where we are breaking the tie in favor of not treating when $a=t$.

Determine what the threshold $t$ is. (Please provide an exact answer.)

$t$= {{t}}

```python
t=20000/50000;t
```

    0.4

Let's look at what happens if instead we had observed $y = \text {negative}$.

Determine what variable $b$ and threshold $s$ should be in the below equation:

$$\begin{eqnarray}
\arg\min_{d \in \{\text{treat}, \text{don't treat}\}}
  \mathbb{E}[C(X, d) \mid Y = \text{negative}]
&=&
\begin{cases}
\text{don't treat} & \text{if } b \le s, \\
\text{treat} & \text{if } b > s.
\end{cases}
\end{eqnarray}$$

$b=$ ?

$[\times    ]$ pX∣Y(healthy∣positive)

$[\times    ]$ pX∣Y(infected∣positive)

$[\times    ]$ pX∣Y(healthy∣negative)

$[\checkmark]$ pX∣Y(infected∣negative)

- In case of $d=$ "treat", we have to spend \$20000 in both cases hence expectation will be 20000.
- In case of $d=$ "don't treat", we have to spend \$0 when persion is healthy and \$50000 when the persion is infected.

 Hence $E = 50000b + 0(1-b) = 50000b$.

Threshold $s$ = {{s}}

```python
s = 20000/50000
```

$s$= {{s}}

Putting together the pieces, the decision rule that achieves the lowest cost can be written as:

$$\begin{eqnarray}
\widehat{x}(y)
&=&\begin{cases}
\text{don't treat} & y=\text{positive}\text{ and }a \le 0.4,\\
\text{treat} & y=\text{positive}\text{ and }a > 0.4,\\
\text{don't treat} & y=\text{negative}\text{ and }b \le 0.4,\\
\text{treat} & y=\text{negative}\text{ and }b > 0.4,
\end{cases}
\end{eqnarray}$$

where $a$ and $b$ are from the previous parts.

Now finally consider when $a=0.09016$ and $b=0.00001$ (these correspond to values from the previous exercises on this medical diagnosis problem setup). What is the optimal treatment plan given the cost table that we have introduced?

If we observe that $Y=\text{positive}$, then the decision with lowest expected cost is:

[$\times    $] treat

[$\checkmark$] don't treat

If we observe that $Y=\text{negative}$, then the decision with lowest expected cost is:

[$\times    $] treat

[$\checkmark$] don't treat

Of course, if $a$ and $b$ had been different, then the decisions we would make to minimize expected cost could change! In fact, this problem shows just how good the medical test needs to be designed before we can even justify treating the patient. A bad test would be one where the optimal decision for minimizing expected cost does not use the result of the test at all — we might as well not do the test!

## Expectations of Multiple Random Variables

How does expected value work when we have many random variables? In this exercise, we look at some specific cases that draw out some important properties involving expected values.

Let's look at when there are two random variables $X$ and $Y$ with alphabets $\mathcal{X}$ and $\mathcal{Y}$ respectively. Then how expectation is defined for multiple random variables is as follows: For any function $f:\mathcal{X}×\mathcal{Y}→R$,

$$\mathbb {E}[f(X, Y)] = \sum _{x \in \mathcal{X}, y\in \mathcal{Y}} f(x, y) p_{X,Y}(x, y).$$

(Note that “$\sum _{x \in \mathcal{X}, y\in \mathcal{Y}}$" can also be written as “$\sum _{x \in \mathcal{X}}\sum _{y \in \mathcal{Y}}$".)

For example:

$$\mathbb {E}[X + Y] = \sum _{x \in \mathcal{X}, y\in \mathcal{Y}} (x + y) p_{X,Y}(x, y).$$

Let's look at some properties of the expected value of the sum of multiple random variables.

First, as a warm-up, show that:

$$\mathbb {E}[X + Y] = \mathbb {E}[X] + \mathbb {E}[Y].$$

This equality is called linearity of expectation and it holds regardless of whether $X$ and $Y$ are independent.

We previously saw that a binomial random variable with parameters $n$ and $p$ can be thought of as how many heads there are in $n$ tosses where the probability of heads is $p$.

A different way to view a binomial random variable is that it is the sum of $n$ i.i.d. Bernoulli random variables each of parameter $p$. As a reminder, a Bernoulli random variable is $1$ with probability $p$ and $0$ otherwise. Suppose that $X_1,X_2,\ldots,X_n$ are i.i.d. $\text{Bernoulli}(p)$ random variables, and $S=\sum_{i=1}^nX_i$.

**Question:** What is $\mathbb{E}[X_i]?$

**Answer:** $\mathbb{E}[X_i] = 1p + 0(1-p)= p$

**Question:** What is $\mathbb{E}[S]?$

**Answer:** $\mathbb{E}[S] = \sum_{i=1}^n\mathbb{E}[X_i]=np$

A widely useful random variable is the indicator random variable $1(⋅)$, defined as

$$\begin{eqnarray}
\mathbb{1}(\mathcal{S})
&=&
\begin{cases}
1 & \text{if }\mathcal{S}\text{ happens}, \\
0 & \text{otherwise}
\end{cases}
\end{eqnarray}$$

where $S$ is an event.

**Question:** Can a Bernoulli random variable corresponding to a coin flip be written as an indicator random variable?

**Answer:** Yes, we can take $\mathcal{S} = \{\text{'H'}\}$

**Question:** Suppose a monkey types 1,000,000,000 characters using the 26 letters of the alphabet. The monkey selects each character independently and is equally likely to choose any of the 26 letters. What is the expected number of occurrences of the word “caesar"?

Hint: First write up the number of occurrences of the word “caesar" as the sum of indicator random variables.

**Solution:** Let $Z_i$ be the indicator random variable that the word “caesar" starts at position $i$. Note that

$$\begin{align} \mathbb {E}[Z_ i]	=& \mathbb {P}(\mbox{the word ``caesar'' starts at position }i)	\\
(\text {by independence})=& \mathbb {P}(i\mbox{-th letter is ``c''})\mathbb {P}((i+1)\mbox{-th letter is ``a''})	\\
& \quad \cdots \mathbb {P}((i+5)\mbox{ -th letter is ``r''})	\\
=& \underbrace{\frac{1}{26}\times \frac{1}{26}\times \cdots \times \frac{1}{26}}_{6\text { times}} \\
=& \frac{1}{(26)^{6}}.\end{align}$$

Then the number of occurrences of “caesar" is given by $Y=\sum _{i=1}^{1,000,000,000-5}Z_ i$, so

$$\mathbb {E}[Y] = \mathbb {E}[\textstyle \sum _{i=1}^{999,999,995}Z_ i] = \textstyle \sum _{i=1}^{999,999,995}\mathbb {E}[Z_ i] = \boxed {999,999,995\times \frac{1}{(26)^{6}}}.$$

Note that in the second equality we used the fact that expectation is linear, even though $X_i$'s are dependent. This is because the linearity of expectation doesn't require independence.

**Question:** Consider a standard 52-card deck that has been shuffled. What is the expected number of times for which a king in the deck is followed by another king? For example, if all four kings are next to each other, then we'd say that there are 3 kings that are followed by another king.

**Solution:** Let $i$ be an index between two cards in the deck, i.e., $i=1$ refers to the index between cards $1$ and $2$. Thus, the range of possible $i$ is $i=1,2,\cdots ,51$. Define an indicator random variable $X_ i$ that is $1$ when the cards at index $i$ and $i+1$ are both kings. Note that

$$\mathbb {E}[X_ i]=\mathbb {P}(i\text {-th card is a king},(i+1)\mbox{-th card is a king})=\frac{4}{52}\cdot \frac{3}{51}.$$

Then the number of kings followed by another king is $Y=\sum _{i=1}^{51}X_ i$, so
$$\mathbb {E}[Y]=51\cdot \frac{4}{52}\cdot \frac{3}{51} =\frac{12}{52}=\boxed {\frac{3}{13}}.$$

**Question:** If $X$ and $Y$ are independent, is it true that $\mathbb {E}[XY] = \mathbb {E}[X]\mathbb {E}[Y]$?

**Solution:** Yes,

$$\begin{align}
\mathbb {E}[XY]	=& \sum _{x}\sum _{y}xyp_{X,Y}(x,y)	\\
(\text {by independence})	=& \sum _{x}\sum _{y}xyp_{X}(x)p_{Y}(y)	\\
=& \sum _{x}\sum _{y}(xp_{x}(x))(yp_{Y}(y))	 \\
=& \Big(\sum _{x}xp_{x}(x)\Big)\Big(\sum _{y}(yp_{Y}(y))\Big)	\\
=& \mathbb {E}[X]\mathbb {E}[Y].
\end{align}$$

Finally, let's look at variance again. As a reminder

$$\text {var}(X) = \mathbb {E}[(X-\mathbb {E}[X])^2].$$

**Question:** If $X$ and $Y$ are independent, is it true that $\text {var}(X + Y) = \text {var}(X) + \text {var}(Y)$?

**Solution:** Yes.

To show this. We'll be using linearity of expectation repeatedly. To keep the notation here from getting cluttered with the squaring, let $\mu _{X}=\mathbb {E}[X]$ and $\mu _{Y}=\mathbb {E}[Y]$. First off, note that

$$\begin{align}\displaystyle \text {var}(X) =& \mathbb {E}[(X-\mu _{X})^{2}]\\
=& \mathbb {E}[X^{2}-2\mu _{X}X+\mu _{X}^{2}]\\
=&\mathbb {E}[X^{2}]-2\mu _{X}\underbrace{\mathbb {E}[X]}_{\mu _{X}}+\mu _{X}^{2}\\
=&\mathbb {E}[X^{2}]-\mu _{X}^{2}.\\
\end{align}$$

Similarly,

$$\text {var}(Y)=\mathbb {E}[Y^{2}]-\mu _{Y}^{2},$$

and

$$\text {var}(X+Y) =\mathbb {E}[(X+Y)^{2}]-(\mu _{X}+\mu _{Y})^{2}.$$

Let's simplify the right-hand side above:

$$\begin{align}\text {var}(X+Y)	=& \mathbb {E}[(X+Y)^{2}]-(\mu _{X}+\mu _{Y})^{2}\\
=& \mathbb {E}[X^{2}+2XY+Y^{2}]-\mu _{X}^{2}-2\mu _{X}\mu _{Y}-\mu _{Y}^{2}	\\
=& \mathbb {E}[X^{2}]+2\underbrace{\mathbb {E}[XY]}_{=\mu _{X}\mu _{Y}\text { by indep.}}+\mathbb {E}[Y^{2}]-\mu _{X}^{2}-2\mu _{X}\mu _{Y}-\mu _{Y}^{2}\\
=&\displaystyle \underbrace{\mathbb {E}[X^{2}]-\mu _{X}^{2}}_{\text {var}(X)}+\underbrace{\mathbb {E}[Y^{2}]-\mu _{Y}^{2}}_{\text {var}(Y)}.
\end{align}$$

**Question:** What is the variance of a $\text {Ber}(p)$ random variable?

**Solution:** For $X_ i \sim \text {Ber}(p)$, we have

$$\begin{align}\mathbb {E}[(X_ i-\underbrace{\mathbb {E}[X_ i]}_{p})^{2}]=&(1-p)^{2}\cdot p+(0-p)^{2}\cdot (1-p)	 =& (1-2p+p^{2})p+p^{2}(1-p)\\
=&p-2p^{2}+p^{3}+p^{2}-p^{3}\\
=&p-p^{2}\\
=&\boxed {p(1-p)}.\end{align}$$

**Question:** Recall that a binomial random variable $S$ that has a $\text {Binomial}(n, p)$ distribution can be written as $S = \sum _{i=1}^ n X_ i$ where the $X_ i$'s are i.i.d. $\text {Ber}(p)$ random variables. What is the variance of $S$?

**Solution:** Since the $X_ i$'s are independent,

$$\text {var}(S) =\text {var}\Big(\sum _{i=1}^ n X_ i\Big) =\sum _{i=1}^ n \text {var}(X_ i) =\boxed {n p (1-p)}.$$

```python

```

week04/02 Exercise on Decision Making.ipynb


# Modeling spiking neural networks with Brian

```python
# If Brian2 is not yet installed, the following will install it
# (otherwise it will print a number of "Requirement already satisfied" lines)
%pip install brian2
```

    Requirement already satisfied: brian2 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (2.4.1)
    Requirement already satisfied: jinja2>=2.7 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from brian2) (2.11.2)
    Requirement already satisfied: sympy>=1.2 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from brian2) (1.6.2)
    Requirement already satisfied: setuptools>=24.2 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from brian2) (49.6.0.post20201009)
    Requirement already satisfied: numpy>=1.15 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from brian2) (1.19.2)
    Requirement already satisfied: pyparsing in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from brian2) (2.4.7)
    Requirement already satisfied: cython>=0.29 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from brian2) (0.29.21)
    Requirement already satisfied: MarkupSafe>=0.23 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from jinja2>=2.7->brian2) (1.1.1)
    Requirement already satisfied: mpmath>=0.19 in /home/marcel/anaconda3/envs/datasci_course/lib/python3.8/site-packages (from sympy>=1.2->brian2) (1.1.0)
    Note: you may need to restart the kernel to use updated packages.

Let's first import "everything" from the Brian 2 package.

We also switch off Brian's "code generation" mechanism, which improves the performance for complex models by generating/compiling/executing C++ code "behind the scenes". For the simple models that we are covering in this tutorial, this is not necessary – it even slows down things due to the need for compilation.

```python
from brian2 import *
prefs.codegen.target = 'numpy'
```

We ask the notebook to include plots directly in the notebook (instead of showing them in a separate window). Note that lines starting with % are specific commands for the jupyter notebook, they won't work in a Python script, for example. We also reduce the length of error messages which tend to get very long in Python.

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

%matplotlib inline
%xmode minimal
```

    Exception reporting mode: Minimal

## Brian's unit system

Brian has a system for physical units and handles conversions between scales as well as the combination of different units automatically. All units can use a long form (e.g. `mvolt` or `nsiemens`), and for the most common units also a short form is available (e.g. `mV`, `nS`, `ms`).

```python
x = -60*mV
y = 10*nS
print(x, x * y)
```

    -60. mV -0.6 nA

Sometimes we need to remove the units, e.g. to hand them over to matplotlib's plotting functions which do not know how to handle Brian's unit system. The easiest way to do this is to divide by the scale we are interested in.

```python
print(x/mV)  # "value of x in mV"
print(y/nS)  # "value of y in nS"
print(y/uS)  # "value of y in µS"
```

    -60.0
    10.0
    0.01

Brian checks the consistency of units, which can be very useful in particular when writing complex equations. If the units do not match, an error is raised:

```python
print(x + y)  # does not make sense
```

## Neurons

Let's create a group of integrate-and-fire neurons. We use the same equation as in last week's tutorial
$$
\tau\frac{\mathrm{d}V}{\mathrm{d}t} = E_L - V + I_\mathrm{stim}
$$
If $V > V_\mathrm{threshold}$: emit a spike and set $V \leftarrow V_\mathrm{reset}$.

```python
# The "start_scope()" call is necessary if we define several models in the same script or notebook.
# It tells Brian to forget about any model defined previously. Otherwise, when we call "run(...)"
# Brian would not know which model to run.
start_scope()

# Constant that are common to all neurons (or later, synapses), can be defined as Python variables
N = 3
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
duration = 100*ms

# A NeuronGroup is a group of neurons that are defined by the same equations. The neurons do not need
# to be all identical, they can have different parameters and/or different initial values for their
# variables. In the following model, the variable V is defined as a differential equation, and in
# addition we add a parameter "I_stim" which is different for each neuron.
# Note that we have to add unit definitions like ": volt" at the end of each variable definition, so
# that Brian can verify that all units are consistent.
neurons = NeuronGroup(N, '''dV/dt = (E_L - V + I_stim)/tau : volt
                            I_stim : volt (constant)  # note, this is rescaled with g_L already''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')

# All variables of the model (here: V and I_stim) are available as attributes of the NeuronGroup
# object "neuron". We can get and set their values by using neuron.<variable_name> .
# Initialize values
neurons.V = V_reset  # same initial value for all neurons
neurons.I_stim = [19, 21, 30]*mV  # one value for each neuron

# After running a model, we have access to the values of all variables at the end of the simulation.
# In most simulations, however, we are also interested in the values of the variables *during* the
# simulation, i.e. their evolution over time. In order to record these values, we need to create
# so called monitors. A "StateMonitor" records the values of variables over time, and a "SpikeMonitor"
# records action potential (which neuron spiked when)

# Record variable "V" in group "neuron". The argument "record=True" is a shorthand for "record the values
# from all neurons in the group". Alternatively, we could give the indices of the neurons we are interested
# in (e.g. "record=[0, 2, 4, 100]" to record from the neurons with the given indices)
v_mon = StateMonitor(neurons, 'V', record=True)

# Record spikes in the group neuron
spike_mon = SpikeMonitor(neurons)

# run simulation for 100ms
run(duration)
```

We could plot things directly by calling `plt.plot`, but by calling `plt.subplots` first we can easily arrange things in subplots.

```python
fig, ax = plt.subplots(2, 1, sharex=True)
# The recorded values are accessible as attributes of the monitor objects.
# A SpikeMonitor provides the attribute "t" for the time of each spike and the attribute "i" for the
# index of the neuron that spiked. E.g. if spike_mon.t returns [10*ms, 20*ms] and spike_mon.i returns
# [0, 5], this means that neuron 0 spiked at 10ms, and neuron 5 spiked at 20ms. These two vectors are
# what we need to make a raster plot of the spiking activity: we plot the time (in ms) on the x axis
# and the indices of the neurons on the y axis:
ax[0].plot(spike_mon.t/ms, spike_mon.i, 'o')
ax[0].set_ylim(-0.1, 2.1)  # Otherwise matplotlib would not show the empty row

# For a StateMonitor, the "t" attribute contains the times when values were recorded (by default at
# every time step of the simulation). Since we recorded the "V" variable, the StateMonitor also has
# a V attribute containing the values of V for all neurons for each time steps as a 2D matrix. For
# plotting, this matrix is in the wrong sense: it has one row for each neuron and one column for each
# time step, but we need one row for each time step and one column for each neuron. We therefore
# transpose the matrix by using ".T":
ax[1].plot(v_mon.t/ms, v_mon.V.T/mV);
# Note that we put a ";" after the last line to avoid some output like
# "[<matplotlib.lines.Line2D at 0x7f83c5db33d0>]" in the notebook
```

### f/I curve

We will now plot an f/I curve as in previous week's tutorial.

```python
start_scope()
N = 100  # 100 neurons to have more values of I_stim
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
duration = 100*ms
neurons = NeuronGroup(N, '''dV/dt = (E_L - V + I_stim)/tau : volt
                            I_stim : volt (constant)  # note, this is rescaled with g_L already''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = V_reset
neurons.I_stim = np.linspace(0, 50, N)*mV  # linspace(0, 50, N) gives us N equally spaced values between 0 and 50

# record membrane potential and spikes
v_mon = StateMonitor(neurons, 'V', record=True)
spike_mon = SpikeMonitor(neurons)

# run simulation
run(duration)
```

```python
# A SpikeMonitor has another attribute "count", which stores the total number of spikes for each neuron
spike_mon.count
```

    <spikemonitor_1.count: array([ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
            0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
            0,  0,  0,  0,  0,  0,  2,  4,  4,  5,  5,  6,  6,  7,  7,  7,  8,
            8,  8,  9,  9, 10, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13,
           14, 14, 14, 15, 15, 15, 16, 16, 16, 16, 17, 17, 17, 18, 18, 18, 18,
           19, 19, 20, 20, 20, 20, 21, 21, 21, 22, 22, 22, 22, 23, 23],
          dtype=int32)>

```python
# Plotting the spike rate (number of spikes per second) against the input current for each neuron gives
# us an f/I curve:
plt.plot(neurons.I_stim/mV, spike_mon.count/duration);
```

Why do we get a "staircase" instead of a smooth curve? This could be because we are not using enough different values of input currents, e.g. imagine what would the curve look like if we only tested 10 different values. But it
turns out that this is not the case here. Let us simulate things again with 1000 neurons (i.e. 1000 different values of I_stim):

```python
start_scope()
N = 1000  # 1000 neurons to have more values of I_stim
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
duration = 100*ms
neurons = NeuronGroup(N, '''dV/dt = (E_L - V + I_stim)/tau : volt
                            I_stim : volt (constant)  # note, this is rescaled with g_L already''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = V_reset
neurons.I_stim = np.linspace(0, 50, N)*mV  # linspace(0, 50, N) gives us N equally spaced values between 0 and 50

# record membrane potential and spikes
v_mon = StateMonitor(neurons, 'V', record=True)
spike_mon = SpikeMonitor(neurons)

# run simulation
run(duration)
```

```python
plt.plot(neurons.I_stim/mV, spike_mon.count/duration);
```

This looks even more like a staircase! If we look closely we see that the values jump up in steps of 10, i.e. there is a line at 100Hz, one at 110Hz, etc. This is because we simulate for only 100ms. To calculate the firing rate "per second", we multiply the number of spikes we get during 100ms with 10. Since there cannot be "half spikes", this means we will never see a firing rate like, say, 105Hz – either a neuron spikes 10 times and therefore the firing rate is 100Hz, or a neuron spikes 11 times and therefore the firing rate is 110Hz. The line gets smoother if we use a longer simulation time:

```python
start_scope()
N = 100  # back to 100 neurons
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
duration = 1000*ms  # 1s instead of 0.1s
neurons = NeuronGroup(N, '''dV/dt = (E_L - V + I_stim)/tau : volt
                            I_stim : volt (constant)  # note, this is rescaled with g_L already''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = V_reset
neurons.I_stim = np.linspace(0, 50, N)*mV  # linspace(0, 50, N) gives us N equally spaced values between 0 and 50

# record membrane potential and spikes
v_mon = StateMonitor(neurons, 'V', record=True)
spike_mon = SpikeMonitor(neurons)

# run simulation
run(duration)
```

```python
plt.plot(neurons.I_stim/mV, spike_mon.count/duration);
```

This looks much better 👍

## Synapses

As mentioned in the lecture, synapses can be modeled on various models of detail. Here, we will use an "exponential current-based" synapse. Let us first try this out with some artficial stimulation:

```python
start_scope()
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
tau_syn = 2*ms
duration = 100*ms

# SpikeGeneratorGroup is an "artificial" neuron group. Instead of writing an equation for the membrane
# potential, etc. we just say: "emit spikes at certain times". This can be very useful for testing
# things. Here we define two groups with a single neuron (size 1). For each group we give a list of the
# neurons that should spike (here this is always "neuron 0", since we only have a single neuron in the
# group), and a list of the times when the neurons should spike. Note that this is the same convention
# as for the attributes "i" and "t" of a SpikeMonitor.
exc_stim = SpikeGeneratorGroup(1, [0, 0], [10, 85]*ms)
inh_stim = SpikeGeneratorGroup(1, [0, 0], [30, 80]*ms)

# Our neuron model is almost the same as previously, but now I_stim is replaced by I_syn. It is no
# longer a constant value, but instead defined by a differential equation: in between two spikes, the
# synaptic current I_syn decays to zero with time constant tau_syn
neurons = NeuronGroup(3, '''dV/dt = (E_L - V + I_syn)/tau : volt
                            dI_syn/dt = -I_syn/tau_syn : volt # decay between spikes''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = E_L

# Synapses
# For each type of connection, we first describe the synaptic model. This consists of two parts: first
# we say which group of neurons are connected with each another (but not yet which specific neurons in
# these groups), and then what should happen if a spike arrives at the synapses. Here, excitatory
# synapses will increase the post-synaptic current I_syn by 1mV for each spike, and inhibitory neurons
# will decrease it by the same amount.
exc_syn = Synapses(exc_stim, neurons, on_pre='I_syn_post += 1*mV')  # "on_pre" = "for each pre-synaptic spike"
inh_syn = Synapses(inh_stim, neurons, on_pre='I_syn_post -= 1*mV')
# Now we need to define which neurons in the "source group" are connected to which neurons in the
# "target group". For very small networks like this, we can define each connection individually. For
# bigger networks, we would rather give some rule (connect all neurons to all neurons, connect neurons
# with a given probability, etc.). Here, we connect neuron 0→0, and 0→2 for the excitatory synapses, and
# 0→1 and 0→2 for the inhibitory synapses. This means that the first target neuron (neuron 0) gets only
# excitatory input, the second target neuron (neuron 1) gets only inhibitory input, and the third neuron
# (neuron 2) gets both.
exc_syn.connect(i=[0, 0],
                j=[0, 2])  # i = "source index", j = "target index"
inh_syn.connect(i=[0, 0],
                j=[1, 2])

# record membrane potential and spikes
v_mon = StateMonitor(neurons, 'V', record=True)
spike_mon = SpikeMonitor(neurons)

# run simulation
run(duration)
```

```python
# We plot the membrane potential of each target neuron in a separate line
fig, axs = plt.subplots(3, 1, sharex=True)
axs[0].plot(v_mon.t/ms, v_mon.V[0]/mV)
axs[1].plot(v_mon.t/ms, v_mon.V[1]/mV)
axs[2].plot(v_mon.t/ms, v_mon.V[2]/mV);
```

### Network

We now create a network of excitatory and inhibitory neurons, with connections among and across the groups. These kind of networks have been used to study network dynamics in the cortex, for example.

```python
start_scope()
N_E = 4000  # excitatory neurons
N_I = 1000  # inhibitory neurons
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
tau_syn = 2*ms
w_e = 1*mV
w_i = 8*mV
duration = 1000*ms

neurons = NeuronGroup(N_E + N_I,
                      '''dV/dt = (E_L - V + I_syn)/tau : volt
                         dI_syn/dt = -I_syn/tau_syn : volt # decay between spikes''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = E_L

exc_neurons = neurons[:N_E]  # Uses "slicing" to get subpopulations of neurons
inh_neurons = neurons[N_E:]

# Connect neurons to each other
exc_syn = Synapses(exc_neurons, neurons, on_pre='I_syn_post += w_e')  # "on_pre" = "for each pre-synaptic spike"
inh_syn = Synapses(inh_neurons, neurons, on_pre='I_syn_post -= w_i')
# We now have 5000 neurons in our networks, so we can no longer type out all the connnections. Instead, we
# ask to make a randomly connected network.
exc_syn.connect(p=0.02)  # 2% connection probability between each pair of neurons
inh_syn.connect(p=0.02)

# record spikes of all neurons, but the membrane potential only of 1 neuron
v_mon = StateMonitor(neurons, 'V', record=0)
spike_mon = SpikeMonitor(neurons)

# run simulation
# for simulations that take longer to run, it is useful to add "report='text'", which will give us some
# output during the simulation telling us how long the simulation has been run and how long it will
# still take.
run(duration, report='text')
```

    Starting simulation at t=0. s for a duration of 1. s
    1. s (100%) simulated in 1s

```python
fig, ax = plt.subplots(2, 1, sharex=True, figsize=(12, 6))
ax[0].plot(spike_mon.t/ms, spike_mon.i, ',')
ax[1].plot(v_mon.t/ms, v_mon.V.T/mV);
```

As we can see, the network isn't doing anything yet. This is because while the neurons are interconnected between each other (and could therefore spike due to the input of the other neurons), none of the neurons spike spontaneously and therefore no neuron propagates any activity to the other neurons. To get the activity going, we need to add some external stimulation, e.g. a current as in the previous models:

```python
start_scope()
N_E = 4000  # excitatory neurons
N_I = 1000  # inhibitory neurons
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
tau_syn = 2*ms
w_e = 1*mV
w_i = 8*mV
duration = 1000*ms

# The equation for V now includes I_ext, and I_ext is given as a parameter of the model
neurons = NeuronGroup(N_E + N_I,
                      '''dV/dt = (E_L - V + I_syn + I_ext)/tau : volt
                         I_ext : volt (constant)
                         dI_syn/dt = -I_syn/tau_syn : volt # decay between spikes''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = E_L
neurons.I_ext = 21*mV  # stimulate the neurons enough so that they spike

exc_neurons = neurons[:N_E]  # Uses "slicing" to get subpopulations of neurons
inh_neurons = neurons[N_E:]

# Connect neurons to each other
exc_syn = Synapses(exc_neurons, neurons, on_pre='I_syn_post += w_e')  # "on_pre" = "for each pre-synaptic spike"
inh_syn = Synapses(inh_neurons, neurons, on_pre='I_syn_post -= w_i')
# We now have 5000 neurons in our networks, so we can no longer type out all the connnections. Instead, we
# ask to make a randomly connected network.
exc_syn.connect(p=0.02)  # 2% connection probability between each pair of neurons
inh_syn.connect(p=0.02)

# record spikes of all neurons, but the membrane potential only of 1 neuron
v_mon = StateMonitor(neurons, 'V', record=0)
spike_mon = SpikeMonitor(neurons)

# run simulation
# for simulations that take longer to run, it is useful to add "report='text'", which will give us some
# output during the simulation telling us how long the simulation has been run and how long it will
# still take.
run(duration, report='text')
```

    Starting simulation at t=0. s for a duration of 1. s
    1. s (100%) simulated in 2s

```python
fig, ax = plt.subplots(2, 1, sharex=True, figsize=(12, 6))
ax[0].plot(spike_mon.t/ms, spike_mon.i, ',')
ax[1].plot(v_mon.t/ms, v_mon.V.T/mV);
```

Since all neurons get the same input current "I_ext", and all neurons start with the same value of "V", all neurons will emit a spike at the same time. This creates the vertical line at the start of the raster plot. This activity
then gets propagated to the other neurons. Since the connections are probabilistic, some neurons will now get a bit more excitatory or a bit more inhibitory input compared to the other neurons, and therefore the activity of the network starts to look "chaotic".

To get a closer look at the activity, we can only record from a subset of the neurons and plot their activity:

```python
start_scope()
N_E = 4000  # excitatory neurons
N_I = 1000  # inhibitory neurons
E_L = -50*mV
V_threshold = -30*mV
V_reset = -55*mV
C = 70*pF
g_L = 10*nS
tau = C/g_L
tau_syn = 2*ms
w_e = 1*mV
w_i = 8*mV
duration = 1000*ms

# The equation for V now includes I_ext, and I_ext is given as a parameter of the model
neurons = NeuronGroup(N_E + N_I,
                      '''dV/dt = (E_L - V + I_syn + I_ext)/tau : volt
                         I_ext : volt (constant)
                         dI_syn/dt = -I_syn/tau_syn : volt # decay between spikes''',
                      threshold='V>V_threshold', reset='V=V_reset', method='euler')
# Initialize values
neurons.V = E_L
neurons.I_ext = 21*mV  # stimulate the neurons enough so that they spike

exc_neurons = neurons[:N_E]  # Uses "slicing" to get subpopulations of neurons
inh_neurons = neurons[N_E:]

# Connect neurons to each other
exc_syn = Synapses(exc_neurons, neurons, on_pre='I_syn_post += w_e')  # "on_pre" = "for each pre-synaptic spike"
inh_syn = Synapses(inh_neurons, neurons, on_pre='I_syn_post -= w_i')
# We now have 5000 neurons in our networks, so we can no longer type out all the connnections. Instead, we
# ask to make a randomly connected network.
exc_syn.connect(p=0.02)  # 2% connection probability between each pair of neurons
inh_syn.connect(p=0.02)

# record spikes of the first 100 neurons, but the membrane potential only of 1 neuron
v_mon = StateMonitor(neurons, 'V', record=0)
spike_mon = SpikeMonitor(neurons[:100])  # We can consider "neurons" like an array of neurons

# run simulation
# for simulations that take longer to run, it is useful to add "report='text'", which will give us some
# output during the simulation telling us how long the simulation has been run and how long it will
# still take.
run(duration, report='text')
```

    Starting simulation at t=0. s for a duration of 1. s
    1. s (100%) simulated in 2s

```python
fig, ax = plt.subplots(2, 1, sharex=True, figsize=(12, 6))
ax[0].plot(spike_mon.t/ms, spike_mon.i, '|')  # we often use a vertical bar for spikes
ax[1].plot(v_mon.t/ms, v_mon.V.T/mV);
```


Python y google están conectados, llamemos a las librerias y conectemos nuestro drive

```
import numpy as np
import matplotlib.pyplot as plt
import sympy as sym
from scipy.optimize import curve_fit
from astropy.visualization import astropy_mpl_style, quantity_support
from google.colab import drive
drive.mount('/content/drive')
```

    Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount("/content/drive", force_remount=True).

Comencemos con una función y guardando los datos.
Para **números** hay dos tipos de variables principales, los números enteros (llamados int) y los reales (llamados float). El separador decimal que tenemos que usar es el punto.
Las variables que almacenan texto se denominan strings (str). Tienen que estar entre comillas sencillas(‘) o dobles («), o si el texto ocupa varias líneas, entre triples comillas dobles («»») .

```
numero1=int(2)
numero2=float(2.5)
print (numero1)
print (numero2)

```

    2
    2.5

Para una función Y(x), defino un arreglo de números en los que evaluaré la función, el rango de x. Por ejemplo, acá x varia entre 0 y 100, en pasos de 0.1.
y la función es $y(x) = 6x^2 - 3x -880$

```
#defino la función
x=np.arange(0,100,0.1)
y=6*x*x-3*x-880

#el gráfico plt
plt.plot(x,y)
plt.title('Función')
plt.ylabel('f(x)')
plt.xlabel('x')

#escritura del archivo, se escribe fila por fila
f = open("chuletita.txt", "w")
for x in np.arange(0,100,1):
        v=6*x*x-3*x-880
        f.write(str(x))
        f.write(" ")
        f.write(str(v))
        f.write('\n')

#Visualizo el gráfico
plt.show ()
#guardo la imagen en png
plt.savefig('xuleta.png')
```

Veamos el archivo chuletita.txt que creamos
Podemos abrirlo con np o con pandas
Este archivo está en nuestro drive

```
x, y = np.loadtxt('chuletita.txt', unpack=True)

plt.plot(x,y, label='Xuletita')

#cuando tengo barras de error x ex y ey
#plt.errorbar(x, y, yerr=erry, fmt=".-"

plt.xlabel('tiempo[ms]')
plt.ylabel('cuentas')
plt.title('Función')
#plt.yscale("log")
plt.show()
```

```

```

Ahora vamos a dibujar una curva de Lorentz
$L_{n}(x-a)=\frac{1}{\pi}\frac{n}{n^{2}+(x-a)^{2}} $ \\
Valores para a y n (0.2 y 10)

```
x=np.arange(-100,100,0.1)
L= (1/np.pi)*(0.2/(0.2*0.2 + (x-12)**2))

#el gráfico plt
plt.plot(x,L)
plt.title('Curva de Lorentz')
plt.ylabel('L(x)')
plt.xlabel('x')
plt.show ()
```

En esta celda se va a calcular $\int_{\alpha}^{\beta}L_n(x-a)dx$

```
x = sym.Symbol('x')
a=sym.integrate((1/np.pi)*(0.2/(0.2*0.2 + (x-5)**2)))
print(a)
sym.plot(a)
b=sym.limit(a, x, 0, '+')
sym.plot(b)
```

    0.318309886183794*atan(5.00000000000005*x - 25.0000000000003)

```
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
from matplotlib import cm
from matplotlib.ticker import LinearLocator, FormatStrFormatter
import numpy as np

fig = plt.figure()
ax = fig.gca(projection='3d')

# los datos
X = np.arange(-5, 5, 0.1)
Y = np.arange(-5, 5, 0.1)
X, Y = np.meshgrid(X, Y)
R = np.sqrt(X**2 + Y**2)
Z = np.cos(R) + np.sin(R)

# graficar las superficie otro color viridis, surf, magma
# https://matplotlib.org/examples/color/colormaps_reference.html
surf = ax.plot_surface(X, Y, Z, cmap=cm.Purples, linewidth=0, antialiased=False)

# eje z
ax.set_zlim(-1.01, 1.01)
ax.zaxis.set_major_locator(LinearLocator(10))
ax.zaxis.set_major_formatter(FormatStrFormatter('%.02f'))

# barra de colores
fig.colorbar(surf, shrink=0.5, aspect=5)

plt.show()

```

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

mpl.rcParams['legend.fontsize'] = 10

fig = plt.figure()
ax = fig.gca(projection='3d')
#defino el parametro
theta = np.linspace(-4 * np.pi, 4 * np.pi, 100)
t = np.linspace(0, 20, 100)
z = np.linspace(-2, 2, 100)
r = z**2 + 1
#especifico la "trayectoria" x(r,theta),y(r, theta),z o w(vu)
x = t
y = t**2
z = t**3
w = theta*r
ax.plot(x, y, z, label='curva paramétrica 1')
ax.plot(x, y, w, label='curva paramétrica 2')
ax.legend()

```


# Linear Systems and Condition Numbers

```
%matplotlib inline
```

## The problem

We are interested in solving linear systems of equations which we write in the form

\begin{equation}
  A {\bf x} = {\bf b},
\end{equation}

where $A$ is a known matrix, ${\bf b}$ a known vector, and ${\bf x}$ is the unknown solution vector that we are trying to find. By convention the system has size $n$ - that is, the matrix has size $n \times n$ and the vectors are column vectors length $n$.

Before attempting to solve the linear system, we recall that not all linear systems can be solved. If the matrix is singular, which is equivalent to $\det(A) = 0$, then the matrix cannot be inverted, and there is no solution.

Now consider the accuracy with which the coefficients of $A$ and ${\bf b}$ can be known. Real numbers cannot be stored on a computer to infinite precision, so the coefficients cannot be accurate beyond, for example, 16 significant figures. In most interesting cases even this is too optimistic: the coefficients of $A$ and ${\bf b}$ will usually be the result of some other numerical or experimental procedure with its own inherent inaccuracies.

This implies one crucial point. There may be linear systems defined by $\left( A, {\bf b} \right)$ that we do not want to solve, as it is _impossible_ to be sure that the solution is sufficiently accurate. That is, a "small" change in the coefficients of e.g. $A$, can lead to a "large" change in the solution. We would usually interpret "small" to mean within the accuracy with which we know the coefficients. What "large" means depends on the accuracy we require on the solution, and is problem dependent.

If the linear system does have a reliable solution - that is, small changes in the coefficients lead to small changes in the solution - we call it **well conditioned**. If it does not, we call it **badly conditioned**. If the linear system is badly conditioned then it cannot be reliably solved: find a different problem.

## Condition Numbers

We cannot, practically, solve not just one linear system, but many neighbouring problems, just to check if the system is reasonable. Instead we want a simple criterion that we can cheaply check to see whether it is worth solving the linear system. We can condense this down to a single number, called the **condition number**.

### Determinants

Why not use the determinant as the condition number? After all, if the determinant is zero the matrix cannot be inverted. However, consider the following matrix:

\begin{equation}
  A = 10^{-1} \begin{pmatrix} 1 & 0 & \dots & 0 \\ 0 & 1 & \ddots & \vdots \\ \vdots & \ddots & \ddots & 0 \\ 0 & \dots & 0 & 1 \end{pmatrix}.
\end{equation}

This diagonal matrix is trivially inverted and perfectly well behaved, no matter how large. However, $\det(A) = 10^{-n}$, which is arbitrarily small for sufficiently large $n$. So the determinant cannot be a good condition number.

### Perturbations

Instead, let us consider what happens if we perturb the coefficients. For simplicity, just perturb the coefficients of the matrix. We do this by considering the matrix $A + \epsilon F$, where $F$ is some arbitrary matrix and $\epsilon$, assumed small, is the "size" of the perturbation. Now the solution will depend on the perturbation: write the solution as ${\bf x}(\epsilon)$. So the problem we are looking to solve is

\begin{equation}
  \left( A + \epsilon F \right) {\bf x}(\epsilon) = {\bf b}.
\end{equation}

We are interested in how much the solution changes as we vary $\epsilon$, or in particular the difference between the "true" solution (when $\epsilon = 0$) and the perturbed solution. Using Taylor expansion (about $\epsilon = 0$) we can write

\begin{equation}
  {\bf x}(\epsilon) - {\bf x}(0) = \epsilon \left. \dot{\bf x} \right|_{\epsilon = 0} + {\cal O} \left( \epsilon^2 \right).
\end{equation}

Going back to the definition of the problem we see

\begin{align}
  && \left( A + \epsilon F \right) \dot{\bf x} + F {\bf x} & = 0 \\
  \implies && \left. \dot{\bf x} \right|_{\epsilon = 0} &= - A^{-1} F {\bf x}(0) + {\cal O} \left( \epsilon \right).
\end{align}

Therefore the *absolute* error induced by the perturbation is

\begin{equation}
  {\bf x}(\epsilon) - {\bf x}(0) = -\epsilon A^{-1} F {\bf x}(0) + {\cal O} \left( \epsilon^2 \right).
\end{equation}

More interesting is the size of the relative error, which is

\begin{equation}
  \frac{\| {\bf x}(\epsilon) - {\bf x}(0) \|}{\| {\bf x} \|} \sim \epsilon \| A^{-1} F \| \sim \| A^{-1} \| \| A \| \left( \epsilon \frac{\| F \|}{\| A \|} \right).
\end{equation}

The final form groups the terms into those we know, or can control (which is just the matrix $A$), and those we do not (the terms corresponding to the perturbation, which are $\epsilon$ and $F$). The unknown terms are also scaled to be dimensionless by "dividing" the unknown $F$ by the known $A$.

This final form gives us our condition number: it is the piece that we can calculate

\begin{equation}
  K(A) = \| A^{-1} \| \| A \|.
\end{equation}

However, we have not yet determined how to compute the "magnitude" or "norm" of a matrix, $\| A \|$.

## Vector and matrix norms

### Vector norms

A norm is a mathematical distance function. Standard norms for e.g. real vectors use the sizes of the components or the "length" of the vector. The most useful for our purposes are the $1, 2$- and $\infty$-norms:

\begin{align}
  \| x \|_{1} & = \sum_{j = 1}^n | x_j |, \\
  \| x \|_{2} & = \sqrt{\sum_{j = 1}^n ( x_j )^2}, \\
  \| x \|_{\infty} & = \max_{j} | x_j |.
\end{align}

For example, the $2$-norm is the "standard" Pythagorean distance.

Note that different norms give different answers when applied to the *same* vector. For example, if ${\bf x} = (-1, 2, 1)$ then

\begin{align}
  \| x \|_{1} & = |-1| + |2| + |1| && = 4, \\
  \| x \|_{2} & = \sqrt{(-1)^2 + (2)^2 + (1)^2} && = \sqrt{6}, \\
  \| x \|_{\infty} & = \max_{j} \left(|-1|, |2|, |1| \right) && = 4.
\end{align}

Hence, when comparing norms of different vectors, it is necessary to always use the same norm.

As an illustration of the different norms, let us restrict to 2 dimensional vectors (which are easy to visualize) and plot all vectors with norm 1 for each of these three norms.

```
import numpy as np
import matplotlib.pyplot as plt
import scipy.linalg as la

x1 = np.linspace(-1.5, 1.5, 100)
x2 = x1
[X, Y] = np.meshgrid(x1, x2)

N1   = np.zeros_like(X)
N2   = np.zeros_like(X)
Ninf = np.zeros_like(X)

for i in range(len(x1)):
    for j in range(len(x1)):
        v = np.array([x1[i], x2[j]])
        N1[i, j]   = la.norm(v, 1)
        N2[i, j]   = la.norm(v, 2)
        Ninf[i, j] = la.norm(v, np.inf)

plt.figure(figsize=(12, 4))
plt.subplot(1, 3, 1)
plt.contour(X, Y, N1, levels = [1])
plt.title(r"$\| {\bf x} \|_{1} = 1$")
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.subplot(1, 3, 2)
plt.contour(X, Y, N2, levels = [1])
plt.title(r"$\| {\bf x} \|_{2} = 1$")
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.subplot(1, 3, 3)
plt.contour(X, Y, Ninf, levels = [1])
plt.title(r"$\| {\bf x} \|_{\infty} = 1$")
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")

plt.tight_layout(pad = 1.0)

plt.show()
```

### Matrix Norms

We really want matrix norm, $\| A \|$. We can use any vector ${\bf y}$ combined with any vector norm to *induce* a matrix norm by defining

\begin{equation}
  \| A \|_{\bf y} = \| A {\bf y} \|
\end{equation}

where the norm on the right hand side is a vector norm. Of course, this definition depends on the choice of vector ${\bf y}$. So we could maximize over *all* vectors ${\bf y}$: however, the size of the norm depends on the size of the vector. Therefore we want to take the magnitude of the vector ${\bf y}$ out: we define the **induced matrix norm** as

\begin{equation}
  \| A \| = \max_{{\bf y}: \| {\bf y} \| = 1} \| A {\bf y} \|.
\end{equation}

Throughout this definition, all norms *must* be the same. For example, the matrix $2$-norm is given by

\begin{equation}
  \| A \|_2 = \max_{{\bf y}: \| {\bf y} \|_2 = 1} \| A {\bf y} \|_2.
\end{equation}

Actually hand calculating the matrix norm using this definition is obviously difficult. However, it can be shown that two particular norms can be greatly simplified. That is

1. the matrix $1$-norm is given by the maximum of the $1$-norm of the *column* vectors of $A$;
2. the matrix $\infty$-norm is given by the maximum of the $1$-norm of the *row* vectors of $A$.

Note that, in contrast to everything else in this section, the $1$-norm is used for the vectors in both cases.

## Condition number

Formally we now have all the tools needed to compute our condition number. Again the condition number depends on the norm used in its calculation, but we would expect that all results have a similar magnitude.

Loosely we interpret the condition number to measure *the amount that inverting the matrix will increase any intrinsic error in the coefficients*.

More precisely, we can look at the relative error that we can measure: the *weighted residual*

\begin{equation}
  {\cal E} = \frac{\| A {\bf x}_{\text{num}} - {\bf b}\|}{\| {\bf b} \|}.
\end{equation}

Here ${\bf x}_{\text{num}}$ is the numerical solution. In principle, the weighted residual could be minimized, but we can never guarantee that it is precisely zero.

Now, it can be shown that

\begin{equation}
  \frac{1}{K(A)} {\cal E} \le \frac{\| {\bf x}_{\text{num}} - {\bf x}_{\text{exact}} \|}{\| {\bf x}_{\text{exact}} \|} \le K(A) {\cal E}.
\end{equation}

The lower bound is not important here: the point is the upper bound. Assume we have minimized the weighted residual to, for example, $10^{-16}$. Then, if the condition number is $\sim 10^{15}$ then the best bound on the relative error is $\sim 0.1$: in other words, we can only guarantee the correctness of the *first* significant digit of our solution!

Lectures/01 - Linear Systems and Condition Numbers.ipynb