# ILLUSTRATION OF STELLAR EVOLUTION

The following tool allows you to investigate how changes in the global stellar parameters affect other properties of the star. You can change the stellar mass in solar masses, the age in percent of the stellar lifetime, and metallicty (\[Fe/H\]):

\begin{equation}
\mathrm{[Fe/H]} = \log_{10}\left( \frac{X_\mathrm{S}}{Z_\mathrm{S}} \right) - \log_{10}\left( \frac{X_\mathrm{S}}{Z_\mathrm{S}} \right)_\odot
\end{equation}

The tool also gives you the option to change the format to create a movie, showing how the plots evolve during the evolution of the star. To use this option, first select your target mass and metallicity. Then change the format setting. Creating a movie may take half a minute.

```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import rcParams
#rcParams['text.usetex'] = True
import ipywidgets as widgets
import matplotlib.animation as ani
from matplotlib import rc
from IPython.display import HTML, display
from ipywidgets import interactive
from ipywidgets import interact, interact_manual
import glob
%matplotlib inline
rcParams['animation.html'] = 'html5'

def read_history(name):

    # Function to read history files from MESA

    dct = {}
    f = open(name)
    for i, line in enumerate(f):
        if i == 5:
            keys = line.split()
            break
    f.close()
    data = np.genfromtxt(name,skip_header=5)
    data= data[~np.isnan(data).any(axis=1)]

    for j, key in enumerate(keys):
        dct[key] = data[:,j]

    return dct
```

```python
# Function to read gyre output

def gyre_read(name):
    data = np.genfromtxt(name,skip_header=5)
    # See https://bitbucket.org/rhdtownsend/gyre/wiki/Output%20Files%20(5.0)
    l = data[:,0]
    n = data[:,2]
    v = data[:,4]
    I = data[:,7]

    mask0 = l == 0
    mask1 = l == 1
    mask2 = l == 2

    return l[mask0], n[mask0], v[mask0], I[mask0], l[mask1], n[mask1], v[mask1], I[mask1], l[mask2], n[mask2], v[mask2], I[mask2]

def echelle(name):
    l0, n0, v0, I0, l1, n1, v1, I1, l2, n2, v2, I2 = gyre_read(name)
    mdnu = np.mean(np.diff(v0))
    x0 = np.mod(v0,mdnu)
    x1 = np.mod(v1,mdnu)
    x2 = np.mod(v2,mdnu)
    return mdnu, x0, v0, x1, v1, x2, v2
```

```python
# The evolution track is plotted

def compplot(MM,FF,AA,Format):

    if Format == "Movie":
        evol = True
    else:
        evol = False

    Mass = []
    FeH = []
    where = []
    with open("./OUTREACH_GRID/overview.txt") as f:
        lines = f.readlines()
        for line in lines:
            words = line.split()
            Mass.extend([float(words[0])])
            FeH.extend([float(words[1])])
            where.extend([words[2]])

    # Find the closest existing model in the grid

    ii = ((np.asarray(Mass)-setmass.value)**2+(np.asarray(FeH)-setFeH.value)**2).argmin()

#    evol = False

    # Read in the properties of the model from the history file

    dct = read_history(where[ii]+"LOGS/history.data")
    nr = dct["model_number"]
    age = dct["star_age"]/1e6
    M = dct["star_mass"]
    R = dct["photosphere_r"]
    L = dct["luminosity"]
    Teff = dct["effective_T"]
    numax = 3090*M/R**2/np.sqrt(Teff/5777.)
    G  = 6.6743e-8
    Mo = 1.98847e33
    Ro = 6.96340e10
    logg = np.log10(G*(M*Mo)/(R*Ro)**2)
    dnu_scal = 135.1*(M*Mo)**(1./2.)*(R*Ro)**(-3./2.)

    # Associate numbers with profile numbers

    Profiles = glob.glob(where[ii]+"LOGS/*.index")
    Profiles = np.genfromtxt(Profiles[0],skip_header=1)
    nrpr = Profiles[:,0]
    prof = Profiles[:,2]

# Which profiles have frequencies (all that are all in the history file)

    ef = [j for j, n in enumerate(nrpr) if n in nr]

    prof = prof[ef]

    newage = setage.value/100.0*max(age)

    fig, ax = plt.subplots(2,figsize=(10,12))
    ax[0].semilogy(Teff,L,lw=2)
    ax[0].set_xlim(max(Teff)+50,min(Teff)-50)
    ax[0].set_xlabel(r'$T_\mathrm{eff} \,\, \mathrm{[K]}$',fontsize=16)
    ax[0].set_ylabel(r'$L \,\, [\mathrm{L_\odot}]$',fontsize=16)
    ax[0].tick_params(axis='both', which='major', labelsize=16)
    ax[0].tick_params(axis='both', which='minor', labelsize=16)

    ax[1].set_xlabel(r"$\nu_{n\ell} \,\,\mathrm{mod}\,\,\Delta \nu\,\,\mathrm{[\mu Hz]}$",fontsize=16)
    ax[1].set_ylabel(r"$\nu_{n\ell}\,\,\mathrm{[\mu Hz]}$",fontsize=16)
    ax[1].tick_params(axis='both', which='major', labelsize=16)
    ax[1].tick_params(axis='both', which='minor', labelsize=16)

    plt.gcf().subplots_adjust(bottom=0.18)
    plt.gcf().subplots_adjust(left=0.18)
    plt.gcf().subplots_adjust(right=0.75)

    tsize  = 16

    # Add text to plot

    x = 1.1
    y = 2

    M_text = plt.text(x, y, r'$M/\mathrm{M}_\odot= \,$'+str(round(M[0],1)),
                 fontsize=tsize,transform=ax[1].transAxes)
    F_text = plt.text(x, y-0.2, r'$\mathrm{Fe/H}= \,$'+str(round(FeH[ii],1)),
                 fontsize=tsize,transform=ax[1].transAxes)
    numax_text = plt.text(x, y-0.4, r'$\nu_\mathrm{max}= \,$'+str(round(numax[0],1))+r"$\,\mu \mathrm{Hz}$",
                     fontsize=tsize,transform=ax[1].transAxes)
    dnu_text = plt.text(x, y-0.6, r'$\Delta \nu \,=$'+str(round(dnu_scal[0],1))+r"$\,\mu \mathrm{Hz}$",
                   fontsize=tsize,transform=ax[1].transAxes)
    logg_text = plt.text(x, y-0.8, r'$\log g = \,$'+str(round(logg[0],1))+r"$\,\mathrm{dex}$",
                    fontsize=tsize,transform=ax[1].transAxes)
    R_text = plt.text(x, y-1.0, r'$R/\mathrm{R}_\odot= \,$'+str(round(R[0],1)),
                 fontsize=tsize,transform=ax[1].transAxes)
    age_text = plt.text(x, y-1.2, r'$\mathrm{Age} = \,$'+str(round(age[0],1))+r"$\,\mathrm{Myr}$",
                   fontsize=tsize,transform=ax[1].transAxes)

    # Is an evolution movie requested or not?
    if not evol:
        iage = abs(age-newage).argmin()
        ax[0].semilogy(dct["effective_T"][iage],dct["luminosity"][iage],"ro")

#    gyre_file = where[ii] + "FREQS/profile" + str(int(prof[iage])) + ".data.GYRE.sgyre_l"
        gyre_file = where[ii] + "FREQS/" + where[ii][len("./OUTREACH_GRID/"):-1] + "_n" + str(int(prof[iage])) + ".profile.FGONG.sgyre_l0"

        mdnu, x0, v0, x1, v1, x2, v2 = echelle(gyre_file)

        ax[1].plot(x0, v0,"bo",label=r"$\ell = 0$")
#    if list(v1):
#        ax[1].plot(x1, v1,"r>",label=r"$\ell = 1$")
#    if list(v2):
#        ax[1].plot(x2, v2,"gs",label=r"$\ell = 2$")

        ax[1].set_xlim(0,mdnu)

    # Reset text
        numax_text.set_text(r'$\nu_\mathrm{max}= \,$'+str(round(numax[iage],1))+r"$\,\mu \mathrm{Hz}$")
        dnu_text.set_text(r'$\Delta \nu \,=$'+str(round(mdnu,1))+r"$\,\mu \mathrm{Hz}$")
        logg_text.set_text(r'$\log g = \,$'+str(round(logg[iage],1))+r"$\,\mathrm{dex}$")
        R_text.set_text(r'$R/\mathrm{R}_\odot= \,$'+str(round(R[iage],1)))
        age_text.set_text(r'$\mathrm{Age} = \,$'+str(round(age[iage],1))+r"$\,\mathrm{Myr}$")
    else:
        ages = np.linspace(min(age),max(age),200)
        pt, = ax[0].plot([], [], "ro")
        echelle0, = ax[1].plot([],[],"bo",label=r"$\ell = 0$")
        plt.legend(loc=2,fontsize=16)

        def init():
            pt.set_data([], [])
            return (pt,)
        def animate(i):
            iage = abs(age-ages[i]).argmin()
            x = Teff[iage]
            y = L[iage]
            pt.set_data(x, y)

#        gyre_file = where[ii] + "FREQS/profile" + str(int(prof[iage])) + ".data.GYRE.sgyre_l"
            gyre_file = where[ii] + "FREQS/" + where[ii][len("./OUTREACH_GRID/"):-1] + "_n" + str(int(prof[iage])) + ".profile.FGONG.sgyre_l0"

            mdnu, x0, v0, x1, v1, x2, v2 = echelle(gyre_file)

        # Animations only work for one x and y array, which makes it somewhat
        # cumbersome when you have two subplots. Here, a simple hack
            del ax[1].lines[0]
            ax[1].plot(x0, v0,"bo")
            ax[1].set_xlim(0,mdnu)
            ax[1].set_ylim(0.9*min(v0),1.1*max(v0))

            numax_text.set_text(r'$\nu_\mathrm{max}= \,$'+str(round(numax[iage],1))+r"$\,\mu \mathrm{Hz}$")
            dnu_text.set_text(r'$\Delta \nu \,=$'+str(round(mdnu,1))+r"$\,\mu \mathrm{Hz}$")
            logg_text.set_text(r'$\log g = \,$'+str(round(logg[iage],1))+r"$\,\mathrm{dex}$")
            R_text.set_text(r'$R/\mathrm{R}_\odot= \,$'+str(round(R[iage],1)))
            age_text.set_text(r'$\mathrm{Age} = \,$'+str(round(age[iage],1))+r"$\,\mathrm{Myr}$")
            return (pt,)

        anim = ani.FuncAnimation(fig, animate, init_func=init,
                               frames=200, interval=100, blit=True)

        video = anim.to_html5_video()
        html = HTML(video)
        display(html)
        plt.close()

#    anim.save('myAnimation2.gif', writer='imagemagick', fps=30)
```

```python
Mass = []
FeH = []
where = []
with open("./OUTREACH_GRID/overview.txt") as f:
    lines = f.readlines()
    for line in lines:
        words = line.split()
        Mass.extend([float(words[0])])
        FeH.extend([float(words[1])])
        where.extend([words[2]])

setmass = widgets.FloatSlider(
    value=max(Mass)/2,
    min=min(Mass),
    max=max(Mass),
    step=0.1,
    description='Mass',
    disabled=False,
    continuous_update=True,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
    )

setFeH = widgets.FloatSlider(
    value=max(FeH)/2,
    min=min(FeH),
    max=max(FeH),
    step=0.1,
    description='[Fe/H]:',
    disabled=False,
    continuous_update=True,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
)

setage = widgets.FloatSlider(
         value=10,
         min=0,
         max=100,
         step=0.1,
         description='Age [%]:',
         disabled=False,
         continuous_update=True,
         orientation='horizontal',
         readout=True,
         readout_format='.1f',
         )

_ = interact(compplot,MM=setmass,FF=setFeH,AA=setage,Format=["Plot","Movie"])
```

    interactive(children=(FloatSlider(value=1.5, description='Mass', max=3.0, min=0.6, readout_format='.1f'), Floa…

```python

```

```python

```


# Tarea 1: Diseño Avanzado de Algoritmos
## Uber
### Daniel San Martín

### Enunciado

Uber es un sistema transporte que nos permite movilizarnos desde un punto de la ciudad a otro. Para esto, solicitamos el servicio de transporte poniéndonos en contacto con algún conductor, el cual va hacia donde estamos para llevarnos a donde deseamos, cumpliendo con la solicitud. Se presenta una ciudad que tiene 1.000 cuadras de oeste a este y 1.000 cuadras de sur a norte, donde todas las cuadras tienen igual largo. Los vehículos se puede desplazar por la ciudad a una velocidad de 1 [cuadra/unidad de tiempo].

La cantidad y posición de los ubers presentes en la ciudad está determinado por el horario: en la mañana hay 100 ubers, en la tarde 200 ubers y en la noche 50 ubers. Las solicitudes de los ubers van llegando cada ciertas unidades de tiempo de manera no uniforme. En cada horario se comienza a contar desde el tiempo 0, por ejemplo, en la mañana las solicitudes se comienzan a recibir desde el tiempo 0. Cada solicitud indica la coordenada de origen, la coordenada de destino y el tiempo en que ésta llega.

Implemente tres algoritmos para cumplir todas las solicitudes dadas

1. El primer algoritmo debe resolver las solicitudes de forma greedy.
2. El segundo algoritmo debe resolver las peticiones de manera aleatoria.
3. El tercer algoritmo debe resolver las solicitudes mediante el algoritmo Harmonic visto en clases.

La idea es que los Uber recorran la mínima distancia total, considerando las distancias de los trayectos que estos deben recorrer, es decir, la distancia donde se encuentre el Uber hacia donde se le esté solicitando y la distancia del origen de la solicitud hasta donde se tenga que llegar. Tenga en cuenta que para este caso, debe utilizar la distancia de Manhattan.

```python
# Python version 3.7.2
import numpy as np # NumPy version 1.16.2
import matplotlib.pyplot as plt # Matplotlib version 3.0.2
import scipy.spatial.distance as sp_dist # SciPy version 1.2.1
from matplotlib.ticker import MaxNLocator
np.random.seed(666)
```

```python
DIR = "data/" # Base directory with ubers and requests
```

#### Distancia de Manhattan

La distancia de Manhattan, también conocida como distancia $L^1$, norma $\ell_1$ o geometría del taxista, se define formalmente como:

\begin{equation}
    d_1(\mathbf{p}, \mathbf{q}) = ||\mathbf{p}  - \mathbf{q}|| = \sum_{i=1}^n |p_i - q_i|,
\end{equation}

donde $\mathbf{p}=(p_1,p_2,...,p_n)$ y $\mathbf{q}=(q_1,q_2,...,q_n)$ son vectores.

Para este caso particular, los vectores pertenecen a un plano por lo que $\mathbf{p}=(p_1,p_2)$, $\mathbf{q}=(q_1,q_2)$ y entonces $d_1(\mathbf{p},\mathbf{q})=|p_1-q_1|+|q_1-q_2|$. Un ejemplo de la diferencia con la distancia euclideana se presenta en la Figura 1.

Figura 1. Las líneas de color rojo, azul y amarillo corresponden a las posibles rutas más cortas entre dos puntos, utilizando la distancia de Manhattan como medida. Imagen obtenida de https://commons.wikimedia.org/wiki/File:Manhattan_distance.svg.

Para simplificar el uso en el código posterior, se implementan 3 versiones distintas para cualcular la distancia de Manhattan. La primera versión se utiliza para comparar dos puntos, la segunda para calcular vectores con $n$ puntos y la última calcula la matriz de distancia entre los uber disponibles y los *requests* correspondientes.

```python
# Manhattan distance
d  = lambda p1, p2: np.linalg.norm(p1 - p2, ord=1) # Compare two points
dv = lambda v1, v2: np.sum(np.abs(v1-v2), axis=1) #  Elementwise distance
dM = lambda v1, v2: sp_dist.cdist(v1, v2, metric='cityblock') # Distance matrix
```

#### Funciones para cargar y guardar archivos

Las siguientes funciones se utilizan para cargar las peticiones y guardar los archivos de salida solicitados. Para optimizar algunos cálculos, los datos se guardan en arreglos de *NumPy*.

```python
def loadRequest(request):
    data = []
    file = open(request)
    for line in file:
        data.append(list(map(int, line.strip().replace("-", " ").split())))
    file.close()
    return np.array(data)
```

```python
def saveOutput(costs, filename):
    output = np.zeros((len(costs), 2), dtype=int)
    output[:,0] = np.arange(len(costs))
    output[:,1] = costs
    np.savetxt(filename, output, delimiter=" ", fmt='%i')
```

#### Función para graficar costos

La función para graficar costos se implementa de forma que se pueda comparar un archivo de *ubers* con cada archivo de *petición*.

```python
def plotCosts(greedy, random, harmonic, req):
    f, axs = plt.subplots(1, 3, sharey=True, figsize=(18, 6))
    for i in range(len(axs)):
        axs[i].plot(np.arange(len(greedy[i])), greedy[i], label="Greedy")
        axs[i].plot(np.arange(len(random[i])), random[i], label="Random")
        axs[i].plot(np.arange(len(harmonic[i])), harmonic[i], label="Harmonic")
        axs[i].set_xlabel("Time")
        axs[i].legend()
        axs[i].grid(True)
    axs[0].set_ylabel("Cost")
    axs[0].set_title("Request " + str(req) + ": Mañana")
    axs[1].set_title("Request " + str(req) + ": Tarde")
    axs[2].set_title("Request " + str(req) + ": Noche")
    plt.tight_layout()
    plt.show()
```

#### Carga de datos

Dada la estructura de los archivos entregados, los datos se manipulan de la siguiente forma:

* Posiciones de los Ubers: Arreglo *NumPy* de $n\times2$, como se detalla a continuación.

|Uber    |$x$     |$y$     |
|--------|--------|--------|
| 1      |$x_1$   |$y_1$   |
| 2      |$x_2$   |$y_2$   |
|$\vdots$|$\vdots$|$\vdots$|
|$n$     |$x_n$   |$y_n$   |

* Peticiones: Arreglo *NumPy* de $m\times5$, como se detalla a continuación.

|Petición|$x_i$      |$y_i$      |$x_f$      |$y_f$      | $t$    |
|--------|-----------|-----------|-----------|-----------|--------|
| 1      |$x_{i_{1}}$|$y_{i_{1}}$|$x_{f_{1}}$|$y_{f_{1}}$|$t_1$   |
| 2      |$x_{i_{2}}$|$y_{i_{2}}$|$x_{f_{2}}$|$y_{f_{2}}$|$t_2$   |
|$\vdots$|$\vdots$   |$\vdots$   |$\vdots$   |$\vdots$   |$\vdots$|
|$m$     |$x_{i_{m}}$|$y_{i_{m}}$|$x_{f_{m}}$|$y_{f_{m}}$|$t_m$   |

```python
# Load positions
manana = np.loadtxt(DIR + 'manana.dat', dtype=int)
tarde = np.loadtxt(DIR + 'tarde.dat', dtype=int)
noche = np.loadtxt(DIR + 'noche.dat', dtype=int)
```

Dado que la estructura de ubers es más simple, solo se utiliza la función ```np.loadtxt()```de *NumPy* para leer el archivo.

```python
# Load requests
req_1 = loadRequest(DIR + 'requests_1.dat')
req_2 = loadRequest(DIR + 'requests_2.dat')
req_3 = loadRequest(DIR + 'requests_3.dat')
```

#### Funciones extras

Posiciones iniciales de los Uber

```python
def plotPositions(manana, tarde, noche):
    fig, (ax1, ax2, ax3) = plt.subplots(ncols=3, sharey=True, figsize=(16, 5))
    ax1.scatter(manana[:,0], manana[:,1], c='y')
    ax2.scatter(tarde[:,0], tarde[:,1], c='r')
    ax3.scatter(noche[:,0], noche[:,1], c='k')
    ax1.set_title("Mañana")
    ax2.set_title("Tarde")
    ax3.set_title("Noche")
    plt.show()
```

Histograma 2D: posición de las solicitudes.

```python
def spatialReqHist(r1, r2, r3):
    xr1_i, yr1_i = r1[:,[0, -1]], r1[:,[1, -1]]; xr1_f, yr1_f = r1[:,[2, -1]], r1[:,[3, -1]]
    xr2_i, yr2_i = r2[:,[0, -1]], r2[:,[1, -1]]; xr2_f, yr2_f = r2[:,[2, -1]], r2[:,[3, -1]]
    xr3_i, yr3_i = r3[:,[0, -1]], r3[:,[1, -1]]; xr3_f, yr3_f = r3[:,[2, -1]], r3[:,[3, -1]]
    f, axs = plt.subplots(2, 3, sharey=True, sharex=True, figsize=(16, 8))
    h1_i = axs[0, 0].hist2d(xr1_i[:,0], yr1_i[:,1], range=[[0, 1000], [0, 1000]])
    h1_f = axs[1, 0].hist2d(xr1_f[:,0], yr1_f[:,1], range=[[0, 1000], [0, 1000]])
    h2_i = axs[0, 1].hist2d(xr2_i[:,0], yr2_i[:,1], range=[[0, 1000], [0, 1000]])
    h2_f = axs[1, 1].hist2d(xr2_f[:,0], yr2_f[:,1], range=[[0, 1000], [0, 1000]])
    h3_i = axs[0, 2].hist2d(xr3_i[:,0], yr3_i[:,1], range=[[0, 1000], [0, 1000]])
    h3_f = axs[1, 2].hist2d(xr3_f[:,0], yr3_f[:,1], range=[[0, 1000], [0, 1000]])
    plt.colorbar(h1_i[3], ax=axs[0,0]); plt.colorbar(h1_f[3], ax=axs[1,0])
    plt.colorbar(h2_i[3], ax=axs[0,1]); plt.colorbar(h2_f[3], ax=axs[1,1])
    plt.colorbar(h3_i[3], ax=axs[0,2]); plt.colorbar(h3_f[3], ax=axs[1,2])
    axs[0, 0].set_title("Request 1"); axs[0, 1].set_title("Request 2"); axs[0, 2].set_title("Request 3")
    plt.show()
```

Histograma número de solicitudes.

```python
def temporalReqHist(r1, r2, r3):
    T = 12001
    rt1 = np.zeros(T); rt2 = np.zeros(T); rt3 = np.zeros(T)
    for t in range(T):
        rt1[t] = len(r1[np.where(r1[:,4] == t)[0]])
        rt2[t] = len(r2[np.where(r2[:,4] == t)[0]])
        rt3[t] = len(r3[np.where(r3[:,4] == t)[0]])
    f, axs = plt.subplots(1, 3, sharey=True, figsize=(16, 4))
    req_i = [rt1, rt2, rt3]
    for i in range(3):
        pet = np.unique(req_i[i])
        n, b, p = axs[i].hist(req_i[i], bins=len(pet))
        print("Request:", i+1)
        for j in range(len(pet)):
            print("%d peticiones: %d" % (pet[j], n[j]))
        axs[i].xaxis.set_major_locator(MaxNLocator(integer=True))
        axs[i].set_title("Request " + str(i+1))
    plt.show()
```

#### Algoritmo Base

El algoritmo base para la solución del problema se basa en manejar 3 estructuras. Estas son:
1. Las peticiones para el tiempo $t$. La estructura peticiones es un arreglo *NumPy* que denotaremos como $r_t$.
2. Los ubers disponibles para atender las peticiones, se denotan como $A$ y corresponde a un arreglo de *NumPy* que guarda las posiciones $(x,y)$ donde se encuentra el vehículo.
3. Los ubers que están atendiendo una petición, $B$, que será un arreglo *NumPy* que guarda $(x,y)$, la posición final en la cual estará el vehículo al finalizar su servicio y $c$ el costo asociado a la distancia de viaje.

El costo se calcula como:

\begin{equation}
    c(t) = \sum_{b\in B_t}d_r(b),
\end{equation}

donde $d_r(\cdot)$ es la distancia recorrida por un auto en servicio $b\in B_t$ en el instante $t$.

Luego, el algoritmo general hace lo siguiente.

* Para cada ventana de tiempo $t$.
    1. A todos los elementos de $B$, le quitamos 1 al costo asociado al servicio.
    2. Si existen ubers en $B$ en los cuales sus costos sean 0, entonces:
        1. Los agregamos a $A$, y los quitamos de $B$.
    3. Si no hay ubers en $A$, pasamos las peticiones de $r_t$ a $r_{t+1}$.
    4. Si hay ubers en $A$, obtenemos las peticiones $r_t$.
    5. Si hay elementos en $r_t$:
        1. Obtenemos las distancias, los autos que atenderán las peticiones y las peticiones que serán atendidas. Esto se obtendrá según los tres criterios solicitados, *Greedy, Aleatorio y Harmonic*.
        2. Quitamos los autos que ya no estarán disponibles,
        3. Pasamos las peticiones no atendidas a la próxima ventana de tiempo,
        4. Calculamos los costos del viaje,
        5. Agregamos los ubers que están atendiendo una petición.
    6. Calculamos la función de costo sumando el costo de $t-1$ más el número de autos en servicio para el tiempo $t$.

```python
def base(uber, request, func):
    T = 12001
    A = np.copy(uber) # Candidates
    B = [] # Ubers in service
    R = np.copy(request) # Requests
    r_t = [] # Request to process in time t
    costs = np.zeros(T) # Array to save costs

    for t in range(T): # Time windows

        # If there are ubers in service
        if len(B) > 0:
            B[:,-1] -= 1 # Remove 1 from cost (it simulates the car moving to end of request destination)
            pos_serv = np.where(B[:,2] <= 0)[0] # Get array position of ubers in destination

            if len(B[pos_serv]) > 0: # If there are ubers that complete the service
                A = np.vstack((A, B[pos_serv, :2])) # Add to candidates array
                B = np.delete(B, pos_serv, axis=0) # Remove from ubers in service

        if len(A) == 0: # If there is no uber available
            R[pos,4] += 1 # Pass requests from t to next time (t+1)
        else:
            # Get positions of requests at time t
            pos = np.where(R[:,4] == t)[0]
            r_t = R[pos,:4] # Copy initial and final positions of requests at time t

            # If there are requests
            if len(r_t) > 0:

                # Get distance, candidates and selected request positions using func criteria
                dist, cand_pos, req_pos_at = func(A, r_t[:,:2])

                # Remove uber from candidates
                A = np.delete(A, cand_pos, axis=0)

                # If selected requests are less than total request at time t
                if len(req_pos_at) < len(r_t):
                    req_pos = np.arange(len(r_t))
                    next_t = np.delete(req_pos, req_pos_at)
                    R[pos[next_t], :4] += 1 # Pass request not attended to t+1

                # Compute costs
                # cost of go to request position
                atte_cost = dist.reshape(-1, 1)
                # Cost of go from initial to final position
                serv_cost = dv(r_t[req_pos_at,:2], r_t[req_pos_at,2:4]).reshape(-1, 1)

                # Create array with [x_f, y_f, cost] for the uber in service
                tmp = r_t[req_pos_at, 2:4] # The new position of uber at end of service
                tmp = np.hstack((tmp, atte_cost + serv_cost))

                # Add uber to service
                if len(B) == 0: # New serv array
                    B = tmp
                else: # Append if there are ubers in service
                    B = np.vstack((B, tmp))

        # Cost function. Number of uber in service
        costs[t] = len(B) + costs[t-1]  # (at t-1 in t=0, just get 0 from numpy cost with zeros)

    return costs
```

#### Algoritmo Greedy

El algoritmo Greedy buscará el par uber-petición con menor distancia, es decir, los "óptimos" en cada tiempo. De forma general se pueden definir los siguientes pasos:

1. Calcular la matriz de distancias.
2. Para cada petición buscamos el auto que minimiza el costo del viaje = costo de llegar al cliente + el costo de llevarlo a su destino. Si hay más peticiones que ubers, solo atendemos las que podamos con la cantidad de ubers disponibles.

```python
def greedyFunc(cand, req):
    # Compute distance matrix, cars in rows and requests in columns
    dist = dM(cand, req)
    # If there are more request than available cars, we only get the max number of cars to service
    n = len(cand) if len(req) > len(cand) else len(req)
    # Arrays for selected cars and requests
    sel_car = np.zeros(n, dtype=int)
    sel_req = np.zeros(n, dtype=int)
    dist_tmp = np.copy(dist) # Temporal distance matrix
    # For each request
    for k in range(n):
        # Search position (i,j) of min distance
        i, j = np.unravel_index(dist_tmp.argmin(), dist_tmp.shape)
        sel_car[k] = i # Selected car
        sel_req[k] = j # Selected request
        # Replace row i and columns j with inf to ommit the car and request in next iteration
        dist_tmp[i] = np.full_like(dist_tmp[i], np.inf)
        dist_tmp[:,j] = np.full_like(dist_tmp[:,j], np.inf)

    return dist[sel_car, sel_req], sel_car, sel_req # Return distance, selected ubers and requests
```

#### Algoritmo aleatorio

El algoritmo aleatorio atiende una petición aleatoria con un uber aleatorio sin importar la distancia de la solicitud. Esto es: atiende cualquier solicitud con cualquier vehículo.

```python
def randomFunc(cand, req):
    # Get random ubers for requests
    n = len(cand) if len(req) > len(cand) else len(req) # If there are more requests than candidates
    rand_pos = np.random.choice(len(cand), n, replace=False) # Random ubers
    rand_req = np.random.choice(len(req), n, replace=False) # Random requests
    dist = dv(cand[rand_pos], req[rand_req]) # Compute distance between selected ubers and requests
    return dist, rand_pos, rand_req # Return distance, selected ubers and requests
```

#### Algoritmo *Harmonic*

Este algoritmo utiliza un criterio aleatorizado para decidir cual uber enviará a cada petición. Esto lo realizada utilizando los pesos definidos como:

\begin{equation}
    \vec{w}_i^{k} = \frac{\frac{1}{d_1(a_k, r_i)}}{\sum_{a\in A}\frac{1}{d_1(a,r_i)}},
\end{equation}

con $0\leq w_i^{k} <1$ y $\sum_{k}w_{i}^k = 1$. Luego los pasos generales son:

1. Calcular la matriz de distancias.
2. Calcular los pesos con la fórmula descrita anteriormente.
3. Para cada petición elegir un auto de forma aleatoria (sin reposición) utilizando los pesos $\vec{w}_i^{k}$. Si la cantidad de peticiones es mayor que la cantidad de ubers disponibles, solo se consideran las que se puedan atender según la disponibilidad de autos.

```python
def harmonicFunc(cand, req):
    # Distance matrix between ubers and requests
    dist = dM(req, cand) # Request in rows and ubers in columns to emulate the table in class notes

    # Compute weights
    dist[dist == 0] = 1e-12 # Avoid distances equal to 0
    M = 1/dist
    M[np.isnan(M)] = 1 # Set 1/0 with 1 (if there are number close to zero)
    M[np.isinf(M)] = 1
    n = np.sum(1/dist, axis=1)
    nr = np.repeat(n.reshape(-1,1), len(cand), axis=1)
    w = M / nr

    # Select ubers using weights
    n = len(cand) if len(req) > len(cand) else len(req) # If there are more requests than candidates
    har_pos = np.full(n, -1, dtype=int) # To keep cars selected

    # For each request
    for i in range(n):
        # Choose random car with probability computed above
        if np.sum(w[i]) == 0: rand_ = np.random.choice(len(cand), 1, replace=False)[0]
        else: rand_ = np.random.choice(len(cand), 1, p=w[i], replace=False)[0]
        # This avoid to select the same car
        while rand_ in har_pos: rand_ = np.random.choice(len(cand), 1, p=w[i], replace=False)[0]
        har_pos[i] = rand_

    dist = dv(cand[har_pos], req[:n]) # Compute distance between selected ubers and requests
    return dist, har_pos, np.arange(n) # Return distance, selected ubers and requests
```

Definimos los algoritmos utilizando la función base y el criterio correspondiente.

```python
def greedy(uber, request):
    return base(uber, request, greedyFunc)
```

```python
def random(uber, request):
    return base(uber, request, randomFunc)
```

```python
def harmonic(uber, request):
    return base(uber, request, harmonicFunc)
```

#### Experimentos

```python
reqs = [req_1, req_2, req_3]
```

```python
ubers = [manana, tarde, noche]
```

Posiciones iniciales de los uber.

```python
plotPositions(manana, tarde, noche)
```

La distribución de los vehículos tanto en la mañana como en la tarde parece ser más homogénea que en la noche, la cual tiende a agruparse en algunas zonas.

Visión general de las posiciones de solicitudes.

```python
spatialReqHist(req_1, req_2, req_3)
```

La primera fila muestra las posiciones de inicio de la solicitud y la segunda fila muestra las posiciones finales. En el caso del archivo *request 1*, notamos ciertos grupos en los cuales se distribuyen las solicitudes. En el caso del archivo *request 2* hay una distribución más homogenea tanto en las posiciones iniciales como las finales. En el último archivo, vemos como las solicitudes se concentran solo en una parte de la ciudad.

```python
temporalReqHist(req_1, req_2, req_3)
```

En general, para los 3 archivos de solicitudes la mayoría de veces hay 1 petición por cada ventana de tiempo. Estas son en promedio 450 veces del total de solicitudes.

Para los algoritmos que contienen componentes aleatorios, se realizarán 10 experimentos y se reportará el promedio para cada $t$.

```python
ub_label = ["Mañana", "Tarde", "Noche"] # For log...

# To save experiments
gree = []
rand = []
harm = []

# For random algorithms
n_exps = 10
exps = np.zeros((2 * n_exps, 12001))

# Compute experiments
for r in range(len(reqs)):
    for u in range(len(ubers)):
        print("Calculando %s - Request %d" % (ub_label[u], r+1))
        print("Greedy...")
        gree.append(greedy(ubers[u], reqs[r]))
        # Experiments for random alg
        for e in range(n_exps):
            print("Random " + str(e + 1) + "...")
            exps[e] = random(ubers[u], reqs[r])
            print("Harmonic " + str(e + 1) + "...")
            exps[e+n_exps] = harmonic(ubers[u], reqs[r])
        # Compute mean
        rand.append(np.mean(exps[:n_exps], axis=0))
        harm.append(np.mean(exps[n_exps:], axis=0))
```

    Calculando Mañana - Request 1
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Tarde - Request 1
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Noche - Request 1
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Mañana - Request 2
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Tarde - Request 2
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Noche - Request 2
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Mañana - Request 3
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Tarde - Request 3
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...
    Calculando Noche - Request 3
    Greedy...
    Random 1...
    Harmonic 1...
    Random 2...
    Harmonic 2...
    Random 3...
    Harmonic 3...
    Random 4...
    Harmonic 4...
    Random 5...
    Harmonic 5...
    Random 6...
    Harmonic 6...
    Random 7...
    Harmonic 7...
    Random 8...
    Harmonic 8...
    Random 9...
    Harmonic 9...
    Random 10...
    Harmonic 10...

#### Guardar Archivos

```python
alg_l = ['greedy', 'random', 'harmonic']
ub_l = ['man', 'tar', 'noc']

for r in range(3):
    for h in range(3):
        saveOutput(gree[3*r+h], 'req-' + str(r+1) + "_" + ub_l[h] + "_greedy.dat")
        saveOutput(rand[3*r+h], 'req-' + str(r+1) + "_" + ub_l[h] + "_random.dat")
        saveOutput(harm[3*r+h], 'req-' + str(r+1) + "_" + ub_l[h] + "_harmonic.dat")
```

#### Comparación de Rendimientos

Para analizar el comportamiento de los algoritmos, se utilizarán dos criterios: 1) comparar los costos finales de cada algoritmo; 2) realizar un análisis de las curvas.

### Costos para el request 1

```python
print("Costos Finales Request 1")
print("* Mañana: ")
print("Greedy:", gree[0][-1])
print("Random:", rand[0][-1])
print("Harmonic:", harm[0][-1])
print("* Tarde: ")
print("Greedy:", gree[1][-1])
print("Random:", rand[1][-1])
print("Harmonic:", harm[1][-1])
print("* Noche: ")
print("Greedy:", gree[2][-1])
print("Random:",rand[2][-1])
print("Harmonic:", harm[2][-1])
```

    Costos Finales Request 1
    * Mañana:
    Greedy: 372619.0
    Random: 652880.2
    Harmonic: 554690.1
    * Tarde:
    Greedy: 353567.0
    Random: 658829.3
    Harmonic: 547851.4
    * Noche:
    Greedy: 445836.0
    Random: 500267.2
    Harmonic: 492335.9

```python
plotCosts(gree[:3], rand[:3], harm[:3], 1)
```

Los costos son similares en la mañana y tarde, pero en la noche vemos un comportamiento distinto en las curvas. Notamos que en este último caso, las curvas del algoritmo *Harmonic* y *Aleatorio* están muy cercanos y se diferencian en aproximadamente $50000$ unidades de costo del algoritmo *Greedy*. Comparando los costos finales, vemos que para los 3 casos el orden de rendimiento de menor a mayor costo es: *Greedy*, *Harmonic* y *Aleatorio*.

Además, podemos incluir que en el caso de la noche los autos parecen estar distribuidos espacialmente de forma compatible con las solicitudes, lo que explicaría la similitud de las curvas de costos entre los algoritmos *Random* y *Harmonic*.

### Costos para el request 2

```python
print("Costos Finales Request 2")
print("* Mañana: ")
print("Greedy:", gree[3][-1])
print("Random:", rand[3][-1])
print("Harmonic:", harm[3][-1])
print("* Tarde: ")
print("Greedy:", gree[4][-1])
print("Random:", rand[4][-1])
print("Harmonic:", harm[4][-1])
print("* Noche: ")
print("Greedy:", gree[5][-1])
print("Random:",rand[5][-1])
print("Harmonic:", harm[5][-1])
```

    Costos Finales Request 2
    * Mañana:
    Greedy: 365751.0
    Random: 422906.2
    Harmonic: 398039.0
    * Tarde:
    Greedy: 366925.0
    Random: 660421.7
    Harmonic: 551789.5
    * Noche:
    Greedy: 240902.0
    Random: 287436.3
    Harmonic: 266749.8

```python
plotCosts(gree[3:6], rand[3:6], harm[3:6], 2)
```

Para estas peticiones, el comportamiento de los algoritmos para los Uber de mañana y noche es muy similar hasta el tiempo 6000 aproximadamente. Posterior a esto recién es posible notar como se separan las curvas. En el caso de la tarde se nota una mayor holgura entre curvas. Comparando los costos finales, vemos que para los 3 casos el orden de rendimiento de menor a mayor costo es: *Greedy*, *Harmonic* y *Aleatorio*.

### Costos para el request 3

```python
print("Costos Finales Request 3")
print("* Mañana: ")
print("Greedy:", gree[6][-1])
print("Random:", rand[6][-1])
print("Harmonic:", harm[6][-1])
print("* Tarde: ")
print("Greedy:", gree[7][-1])
print("Random:", rand[7][-1])
print("Harmonic:", harm[7][-1])
print("* Noche: ")
print("Greedy:", gree[8][-1])
print("Random:",rand[8][-1])
print("Harmonic:", harm[8][-1])
```

    Costos Finales Request 3
    * Mañana:
    Greedy: 96519.0
    Random: 170483.4
    Harmonic: 143476.8
    * Tarde:
    Greedy: 92403.0
    Random: 206702.7
    Harmonic: 161928.9
    * Noche:
    Greedy: 101494.0
    Random: 154886.1
    Harmonic: 132563.2

```python
plotCosts(gree[6:], rand[6:], harm[6:], 3)
```

En este último caso las curvas muestran comportamientos levemente distintos para cada período del día, aunque gráficamente se nota que *Harmonic*, que está en el centro, se acerca más al algoritmo *Random* que al *Greedy*. Algo interesante que destacar es la función de costos del algoritmo *Greedy* que es similar en cada período del día. Los mayores costos para los algoritmos *Harmonic* y *Random* están en la tarde. Comparando los costos finales, vemos que para los 3 casos el orden de rendimiento de menor a mayor costo es: *Greedy*, *Harmonic* y *Aleatorio*.

### Conclusiones

En general, el comportamiento de algoritmos para las peticiones siguen el orden (de menor a mayor costo):

1. Greedy
2. Harmonic
3. Aleatorio

Esto de alguna forma se puede prever, dado que el algoritmo *Greedy* busca las distancias mínimas para las peticiones en cada tiempo $t$, por ende debería mantener los ubers ocupados por menos tiempo. Un enfoque completamente aleatorio, no toma en consideración las distancias, por lo que los ubers pueden tomar cualquier petición aun cuando un uber disponible esté al otro lado de la ciudad. El algoritmo *Harmonic* está justo al medio, puesto que combina un enfoque de óptimo local con aleatoriedad.

Si recordamos la distribución de solicitudes, podemos entender que los mayores costos sean para el primer archivo de solicitudes dado que estas están agrupadas en distintas zonas de la ciudad, lo que hace que los vehículos tengan que recorrer mayores distancias. Con respecto al segundo archivo de solicitudes, los costos disminuyen levemente y puede estar relacionado con que al final de cada viaje los vehículos se encuentran dispersos en la ciudad, lo que tiene un efecto positivo al momento de buscar el uber que tomaría el servicio. Para el último archivo, las solicitudes están concentradas en una zona de la ciudad, por lo que se entiende que existe un menor costo.

Los mayores costos para cada algoritmo se encuentran en el período de la tarde y alguno de los factores involucrados en este comportamiento puede ser que los viajes recorran mayor distancia, que los uber estén mal ubicados al momento de ser solicitados o por la mayor disponibilidad de vehículos, lo que permite mantener más vehículos en servicio. En el período de la noche, los algoritmos tuvieron un comportamiento similar (en general), pero para el archivo de *request 1* este comportamiento fue el de menor diferencia.


```python
# general imports usually needed
import numpy as np
import matplotlib as mpl
import matplotlib.animation
import matplotlib.pyplot as plt
import seaborn as sbn
import pandas as pd
```

```python
# libraries more specific to this lecture notebook
import os.path
import sys
sys.path.append('../../src')
from ml_python_class.config import DATA_DIR

from sklearn.linear_model import LinearRegression
from sklearn.linear_model import SGDRegressor
from sklearn.linear_model import Ridge
from sklearn.linear_model import Lasso
from sklearn.linear_model import ElasticNet
from sklearn.preprocessing import PolynomialFeatures
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
```

```python
# notebook wide settings to make plots more readable and visually better to understand
np.set_printoptions(suppress=True)

#%matplotlib widget
#%matplotlib inline

plt.rc('axes', labelsize=14)
plt.rc('xtick', labelsize=12)
plt.rc('ytick', labelsize=12)
plt.rc('figure', titlesize=18)
plt.rc('legend', fontsize=14)
plt.rcParams['figure.figsize'] = (12.0, 8.0) # default figure size if not specified in plot
plt.style.use('seaborn-darkgrid')

```

# 4. (Chapter 4) Training Models

In this chapter we will begin our study of Machine Learning algorithms in earnest.  Up
to this point you have gotten a taste of using some ML models as black boxes.
You don't really know internally what they are doing or how they work.  But still, learning
the API of how to create data sets and set up models for training, and knowning in
general what supervisied learning is and how you train a model to fit a dataset is
sufficient to start working with ML.

However, at some point you will be working with a dataset and you will get some
weird or unexpected results.  Also there are myriads of parameters and hyper-parameters,
not to mention issues with formatting and cleaning your data, and tweaking
training procedures, etc. that will all always be a bit mysterious and/or you will only
learn by wrote checklists, if you don't understand some of the fundamentals of the
internal implemenations of the various ML algorithms you use.

Understanding whats under the hood will help you quickly understand what types of ML
models will be promising or not for the data you have.  It will give you an understanding
of the hyper-parameters the model uses, and what you might want to try for these parameters
to get good results.  Understanding whats under the hood will also allow you to debug
and spot issues you have with training and debugging your models.  It will allow you
to perform error analysis, and better understand why your models may be working, and why
they may be failing, so that you can tweak things to improve their performance.

In short, while you can be a push-button/check-list using data analyst, if you eventually
want to improve your skills and become a better data scientist, you need to start
learning the internal working of ML algorithms.  Our goal in this class is to begin
your journey along this path.  We won't become experts on any of the ML methods we
will look at, but we hope to introduce you to the fundamental concepts and inner
workings, so that you can beging to understand more deeply how the techniques work.

In this chapter, and units of our course, we will begin by looking at **linear regression**
(which we have used already), followed by **logistic regression**.  These units are very
important, as these models have basic concepts that are often similar across many types
of ML algorithms.  We will look at the idea of the **cost function** (or **fitness function**),
and how this can be used with techniques like **gradient descent** to fit the parameters
of a model in general to make predictions about a set of data.  Linear regression
is a technique for performing regression modeling (predict real valued outputs), while
logistic regression is useful for classification (despite its name, which is a bit
misleading at best).  By comparing similarities and differences of cost functions and
fitting models between regression and classification tasks, we will illustrate some general
principles of ML that apply to many types of supervisied learning models, whether
regression or classification tasks.

## 4.6 Logistic Regression

The linear regression examples we have used up to this point have all been applied to regression problems.  Recall
that by regression we mean we are fitting a model or forming a hypothesis where the output can be a real valued number
with in theory an infinite amount of precision.

But besides regression problems, another common type of supervisied learning problem we normally need to work with are
classification problems, where the ouput is a set of discrete categories.  In the simplest case, the labeled outputs
are binary, like yes/no, true/false or spam/ham classifications.

We need to use a slightly modified approch to solve a classification problem.  Some regression algorithms can be used for
classification as well (and vice versa).  Logistic regression (despite being arguably misnamed) is commonly
used to estimate the probability an instance belongs to a particular class.

### Estimating Probabilities

We will start by considering a binary classification first, and we will use labels of 0 and 1 to represent the false/true
classes respectively.  A logistic regression starts initially in the same way as a linear regression, by computing the weighted
sum of the input features (blus a bias term).  But instead of outputting this result directly like for linear regression,
it outpus the **logistic** (also called **logit**, also called the **sigmoid** function) of this result.

\begin{equation}
\hat{p} = h_{\theta}(x) = \sigma \big( x^T \theta \big)
\end{equation}

This equation has the same weighted sum calculation as for linear regression ( $x^T \theta$ ).  But the
function $\sigma(\cdot)$ here denotes the logistic function.  The standard form of the Logistic/Sigmoid function is:

\begin{equation}
\sigma(z) = \frac{1}{1 + e^{-z}}
\end{equation}

```python
def sigmoid(z):
    """The basic sigmoid or logistic function.  Called sigmoid because of its S-shaped
    output.
    """
    return 1.0 / (1.0 + np.exp(-z))
```

```python
z = np.linspace(-10.0, 10.0, 250)
plt.plot(z, sigmoid(z), label=r'sigmoid $\sigma(z) = \frac{1}{1 + e^{-z}}$')
plt.xlabel('z')
plt.legend();
```

Notice that the result from the weighted sum ($z = x^T \theta$), no matter if it ends up as a very large negative
result or a very large positive result, it will always be squashed to some value between 0 and 1.  Results below
-5 or above 5 get effectively logited to 0 and 1 respectively, and values between will vary somewhere in between 0 and 1.

If we think of the output of the sigmoid function as a probability estimate, then we can interpret this output
as the probability $\hat{p}$ that the result is the positive (1) outcome for a binary classification.  Once the logistic
regression model has estimated the probability $\hat{p} = h_{\theta}(x)$ that an instance $x$ belongs to the
positive class, we can use a threshold to make our final prediction:

\begin{equation}
\hat{y} =
\begin{cases}
0 & \text{if} \;\; \hat{p} < 0.5 \\
1 & \text{if} \;\; \hat{p} \ge 0.5
\end{cases}
\end{equation}

Notice that, using this threshold, when $z < 0$ then we will predict the negative case or 0, and when $z \ge 0$ we
predict the positive case 1.

### Training and Cost Function

Given this modification of the weighted sum, we need to also modify the way we train our models.
The objective of training for a binary classifier is to set the paramter vector $\theta$ so that the
model estimates high probabilities for positive instances ($y = 1$) and low probabilities for
negative instances ($y = 0$).  This idea is captured by a cost function like the following

\begin{equation}
c(\theta) =
\begin{cases}
-\log(\hat{p}) & \text{if} \;\; y = 1 \\
-\log(1 - \hat{p}) & \text{if} \;\; y = 0
\end{cases}
\end{equation}

To understand this cost function, think of the two cases of $y = 1$ and $y = 0$ separately.  For the positive case
when we want the model to predict $y = 1$ the cost looks like the following (remember that $\hat{p}$ is
the output from the sigmod, so it will always be a value between 0 and 1):

```python
p = np.linspace(0.001, 1, 200) # log not defined at 0, so start at very small value
plt.plot(p, -np.log(p), label=r'positive case where y = 1')
plt.xlabel(r'$\hat{p}$')
plt.ylabel(r'cost $c(\theta)$')
plt.legend();
```

Again remember this is the cost function for all training instances where the correct answer is $y = 1$.
When the prediction probability $\hat{p}$ is 1.0, the cost of our cost function approaches 0.
This is what we want, the model is predicting well when it says there is a high probability it
is the $y = 1$ case when that is true.  But when $y = 1$ and the model is predicting a small
probability it is the positive case, we want the cost penality to be high for that prediction.

Likewise you will get a similar though mirrored cost function for the $y = 0$ training cases:

```python
p = np.linspace(0, 0.9999, 200) # log not defined at 0, so start at very small value
plt.plot(p, -np.log(1.0 - p), label=r'negative case where y = 0')
plt.xlabel(r'$\hat{p}$')
plt.ylabel(r'cost $c(\theta)$')
plt.legend();
```

Make sure you see that the cost functions make sense.  For the 2 cases, they give low costs when the model is
making a good prediction, and the cost rapidly increases when the model is making a bad prediction.

Finally, to use this cost function, as we did before for the MSE, we have to average this cost function over the
whole training set of training inputs.  We can use a little bit of mathematical trickery to write this as a
single expression, even though our cost function has been expressed as a piecewise function so far.

\begin{equation}
J(\theta) = -\frac{1}{m} \sum_{i=0}^{m-1} \Big[ y^{(i)} \log \big( \hat{p}^{(i)} \big) + \big( 1 - y^{(i)} \big) \log \big( 1 - \hat{p}^{(i)} \big)   \Big]
\end{equation}

As before we have changed to 0 based indexing here, which is different from the text, to reflect our Python implementation.
Notice that inside of the summation, when $y = 1$ the left hand term will remain, but the right hand term will get
zeroed out.  And likewise when $y = 0$ the left part gets zeroed out.

There is no known closed form solution to compute the value of $\theta$ that minimizes this cost function
(no equivalent of the normal equation).  So we cannot "solve" this analytically like for linear regression.

But this cost function is convex, like the linear regression MSE cost function.  So gradient descent (or any
optimization method) is guaranteed to find the global minimum (if the learning rate is not too large and you
wait long enough).

The partial derivatives of the cost function with regards to the $j^{th}$ model parameter $\theta_j$ is given by:

\begin{equation}
\frac{\partial}{\partial \theta_j}  J(\theta) = \frac{1}{m} \sum_{i=0}^{m-1} \Big( \sigma \big( \theta^T x^{(i)} \big) - y^{(i)}  \Big) x_j^{(i)}
\end{equation}

It is beyond the scope of this course to understand how the partial derivative of the
cost function is derived here.  But you should go back and compare this equation
to the derivative of the MSE cost function that was given for linear regression.  They are
very similar (on purpose, this is one of the reasons for picking the logit
here).  In fact the only difference is that we have to transform the
weighted sum term here using the sigmoid function, which we do not do for
the MSE derivative.  This means that we can use basically the same implementation
of gradient descent, with only a very small modification, for both
linear and logistic regression.

Once we can calculate the gradients, we can use gradient descent in exactly the
same ways as discussed before for linear regression.  We can use batch gradient descent, stochastic or
mini-batches.

### Decision Boundaries

The best way to visualize a logistic regression model is to determine and
plot the decision boundary that the model produces.  We will illustrate
the concept of a decision boundary for classification problems using the
well known iris dataset.  This dataset has only 150 instances, with 4
separate features (sepal length, sepal width, petal length, petal width).  This
is a classification problem, there are 3 classes of irises in the
dataset, Setosa, Versicolor and Virginica.  We will later talk about how to
generalize logistic regression to problems with more than 2 classes.  But here,
we will collapse the Setosa and Versicolor cases into one, and simply
classify either Virginica or not Virginica.

Start by getting the iris dataset from `sklearn`.

```python
from sklearn import datasets
iris = datasets.load_iris()

# this is a dictionary, here are the items of the dataset we can use
list(iris.keys())
```

    ['data',
     'target',
     'frame',
     'target_names',
     'DESCR',
     'feature_names',
     'filename']

```python
# extract the input features, we are only using 1 feture to begin with, petal width
X = iris["data"][:, 3:]

# make the labels, 1 if virginica and 0 if not-virginica
y = (iris["target"] == 2).astype(np.int)
```

Use `sklearn` to train a `LogisticRegression` model.

```python
# create and train a linear model using Logistic Regression
from sklearn.linear_model import LogisticRegression
```

```python
log_reg = LogisticRegression()
log_reg.fit(X, y)
```

    LogisticRegression()

Here since we only use a single feature, the decision boundary will basically occur
at some specific petal width.  This will be the point where the
estimated probability of the learned hypothesis is at 0.5 (our threshold
value).

Lets visualize this boundary, and plot some of the instances in our
training set as well.

```python
X_new = np.linspace(0, 3, 1000).reshape(-1, 1)
y_proba = log_reg.predict_proba(X_new)
plt.plot(X_new, y_proba[:,1], 'g-', label="Virginica");

# rest of figure shown in textbook
plt.plot(X_new, 1.0 - y_proba[:,1], 'b--', label='not Virginica')

# determine the decision boundary value and visualize it
decision_index = min(np.where(y_proba[:,1] > 0.5)[0])
decision_value = X_new[decision_index]
print("Decision boundary value", decision_value)
plt.plot([decision_value, decision_value], [0, 1], 'k:')

# plot Virginica and not Virginica points to visualize how well the decision boundary works
not_virginica_width = X[y == 0]
not_virginica_y = np.zeros(not_virginica_width.shape)
plt.plot(not_virginica_width, not_virginica_y, 'bs')

virginica_width = X[y == 1]
virginica_y = np.ones(virginica_width.shape)
plt.plot(virginica_width, virginica_y, 'g^')

# add labels and such
plt.annotate(r'Decision Boundary',
             xy=(decision_value, 0.1), xycoords='data',
             xytext=(-70, 0), textcoords='offset points', fontsize=18)
plt.arrow(decision_value, 0.07, -0.33, 0, color='blue', linewidth=1.0, width=0.01)
plt.arrow(decision_value, 0.93, 0.33, 0, color='green', linewidth=1.0, width=0.01)

plt.xlabel('Petal Width (cm)')
plt.ylabel('Probability')
plt.legend();
```

The petal widths of Virginica range from 1.4 cm to 2.5 cm, while the other iris flowers have
smaller petal lengths, ranging from 0.1 cm to 1.8 cm.  Notice that there is a bit of overlap,
we cant get a perfect prediction with this attribute alone (petal width) of the iris
instances.

There is a decision boundary at around 1.66 cm that the classifier learns here.  This will
be the location of the learned parameter models where the probability for an input of the
petal width features results in a probability value of 0.5.  The black dotted line represents
the decision boundary, the location where the model predicted probability is 0.5.  A little smaller
than this decision boundary, and the model will predict the iris is not a Virginica.  A little
bigger and it predicts it is Virginica.

The 0.5 threshold is used as the decision boundary.  Thus if we ask the logistic regressor to
give us its best prediction, we will get a definitivie answer. For example:

```python
log_reg.predict([[1.7], [1.5]])
```

    array([1, 0])

The logistic regressor we used previously with a single feature can be used with 2 or many
more features.  But the logistic regressor we are currently using creates a linear model
for the data.  This means that the decision boundary will always be a straight line (or hyperplane
in 3 or more dimensions).  For example, if we train the Virginica/Not Virginica classifier on
two features, petal width and petal length, we can see that the decision boundary that results
is a line in the 2 dimensions.

```python
# extract the input features, use 2 features, petal length and petal width this time
X = iris["data"][:, 2:]

# make the labels, 1 if virginica and 0 if not-virginica
y = (iris["target"] == 2).astype(np.int)
```

```python
log_reg = LogisticRegression()
log_reg.fit(X, y)
```

    LogisticRegression()

```python
# create a mesh so we can use a contour plot to visualize the decision boundary
x0, x1 = np.meshgrid(
        np.linspace(2.9, 7, 500).reshape(-1, 1),
        np.linspace(0.8, 2.7, 200).reshape(-1, 1),
    )
X_new = np.c_[x0.ravel(), x1.ravel()]

# calculate probability on the mesh for the contour plot
y_proba = log_reg.predict_proba(X_new)

# plot the actual points, green triangles for Virginica, blue squares are not Virginica
plt.figure(figsize=(12, 12))
plt.plot(X[y==0, 0], X[y==0, 1], "bs")
plt.plot(X[y==1, 0], X[y==1, 1], "g^")

# create contour plot
zz = y_proba[:, 1].reshape(x0.shape)
contour = plt.contour(x0, x1, zz, cmap=plt.cm.brg)
plt.clabel(contour, inline=1, fontsize=12)

# plot the decision boundary as a dashed thick black line
left_right = np.array([2.9, 7])
boundary = -(log_reg.coef_[0][0] * left_right + log_reg.intercept_[0]) / log_reg.coef_[0][1]
plt.plot(left_right, boundary, "k--", linewidth=3)

# add in some labels and what not
plt.text(3.5, 1.5, "Not Virginica", fontsize=14, color="b", ha="center")
plt.text(6.5, 2.3, "Virginica", fontsize=14, color="g", ha="center")
plt.xlabel("Petal length", fontsize=14)
plt.ylabel("Petal width", fontsize=14)
plt.axis([2.9, 7, 0.8, 2.7]);

```

Just like the linear regression models we discussed above, Logistic Regression models can be regularized using
$\ell_1$ or $\ell_2$ penalities.  Scikit-learn adds an $\ell_2$ penality by default.

### Softmax Regression

The logistic regression model can be generalized to model multiple classes directly.  One approach is to train multiple
logistic classifiers on all of the combinations of class/not class and choose the one that gives the highest probability when
we want to make predictions (discussed previously).

Another approach supports multi-classes directly, called the **softmax regression** or **multinomial logistic regression**.  The idea
is similar.  The softmax regression first computes a score $s_k(x)$ for each class $k$, then it estimates the probability of each
class by applying the **softmax function** (also called the **normalized exponential**).  The equation to compute the initial score for
each class should look familiar, as it is just like the equation for linear regression prediciton.

\begin{equation}
s_k(x) = x^T \theta^{(k)}
\end{equation}

This means that each class $k$ has its own dedicated parameter vector $\theta^{(k)}$.  So we are essentially again proposing separate classifiers
for each class.  All these vectors are typically stored as rows in a **parameter matrix** $\Theta$.

Once you have computed the score of every class for the instance $x$, you can estimate the probability $\hat{p}_k$ that the instance belongs to class
$k$ by running the scores through the softmax function.  It computes the exponential of every score, then normalizes them (dividing by the
sum of all the exponentials).  The scores are generally called logits or log-odds.

\begin{equation}
\hat{p}_k = \sigma(s(x))_k = \frac{\exp \big(s_k(x) \big)}{\sum_{j=1}^{K} \exp \big( s_j(x) \big)}
\end{equation}

Where

- $K$ is the number of classes, if we have to we can renumber using 0 based indexing for implementation in Python
- $s(x)$ is a vector containing the scores of each class for the instance $x$.
- $\sigma \big( s(x) \big)_k $ is the estimated probability that the instance $x$ belongs to class $k$ given the scores of each class for that instance.

Just like the logistic regression classifier, the softmax regression classifier predicts the class with the highest estimated probability:

\begin{equation}
\hat{y} = \underset{k}{\text{argmax}} \; \sigma(s(x))_k = \underset{k}{\text{argmax}} \; s_k(x) = \underset{k}{\text{argmax}} \; \Big( \big(\theta^{(k)} \big)^T x \Big)
\end{equation}

An example of training using the softmax regression classifier should make this more clear.  For training, we will minimize the
defined probability function $\hat{p}_k$ as our objective function.  The following equation is known as the **cross entropy**
cost function.

\begin{equation}
J(\Theta) = -\frac{1}{m} \sum_{i=0}^{m-1} \sum_{k=0}^{K-1} y_k^{(i)} \log \big( \hat{p}_k^{(i)} \big)
\end{equation}

Here $y_k^{(i)}$ is the target probability that the $i^{th}$ instance belongs to class $k$.  It will be either equal to 1 or 0 depending
on whether the instance $i$ belongs to the class or not.

Notice that when there are just two classes ($K=2$) this cost function is equivalent to the logistic regression's cost function.

Given this cost function, we need the gradient vector of the cost function with regards to $\theta^{(k)}$.  The derivation is the same
as for the logistic regression.  We end up with the following formula for calculating the gradients

\begin{equation}
\bigtriangledown_{\theta^{(k)}} J(\Theta) = \frac{1}{m} \sum_{i=0}^{m-1} \big( \hat{p}_k^{(i)} - y_k^{(i)}  \big) x^{(i)}
\end{equation}

Given this expression for the gradient, we can use gradient descent to perform the softmax regression.  Of course, this will all be
done for us using scikit-learn.  Lets use scikit-learn's `LogisticRegression` class to classify all 3 iris flower classes.
Scikit-learn `LogisticRegression` uses one-versus-all by default when you train it on more than two classes, but you can set the
`multi_class` hyperparameter to `"multinomial"` to switch it to softmax regression instead.  You must also specify a solver
(gradient descent algorith) that supports softmax regression, such as the `"lbgs"` solver.  It also applies $\ell_2$ regularization
by default, which you can control using the hyperparameter $C$.

```python
X = iris["data"][:, (2,3)] # still only using petal length and petal width
y = iris["target"] # but use all of the targets
```

```python
softmax_reg = LogisticRegression(multi_class="multinomial", solver="lbfgs", C=10)
softmax_reg.fit(X, y)
```

    LogisticRegression(C=10, multi_class='multinomial')

So once the mode is trained, we can ask it to predict.  Like with one-versus-all, asking to `predict` will give the predicted class label.
And we can use the `predict_proba` function to see the individual softmax probability estimates for each class the model is trained with.

```python
# prediction for an iris with 5cm petal length and 2cm petal width
softmax_reg.predict([[5,2]])
```

    array([2])

```python
# probabilities, these are in the order defined by y=0,1,2
# which is Setosa, Versicolor and Virginica respecitvely
softmax_reg.predict_proba([[5,2]])
```

    array([[0.00000064, 0.057493  , 0.94250636]])

Softmax gives an almost 94.3% probability prediction that this iris should be in the Virginica class, with a 5.7% probability that it is
the versicolor.

We can also plot the resulting decision boundary reached by this softmax regressor.  Notice that the decision boundaries between any 2
classes are still linear.

```python
# create a meshgrid so we can make a contour plot of the decision boundaries
x0, x1 = np.meshgrid(
        np.linspace(0, 8, 500).reshape(-1, 1),
        np.linspace(0, 3.5, 200).reshape(-1, 1),
    )
X_new = np.c_[x0.ravel(), x1.ravel()]

# make predictions over the grid for the contour plot
y_proba = softmax_reg.predict_proba(X_new)
y_predict = softmax_reg.predict(X_new)

# plot the contour map
plt.figure(figsize=(12, 12))
zz1 = y_proba[:, 1].reshape(x0.shape)
zz = y_predict.reshape(x0.shape)
from matplotlib.colors import ListedColormap
custom_cmap = ListedColormap(['#fafab0','#9898ff','#a0faa0'])

plt.contourf(x0, x1, zz, cmap=custom_cmap)
contour = plt.contour(x0, x1, zz1, cmap=plt.cm.brg)

# Plot the actual data points, color and shape coded
plt.plot(X[y==2, 0], X[y==2, 1], "g^", label="Iris-Virginica")
plt.plot(X[y==1, 0], X[y==1, 1], "bs", label="Iris-Versicolor")
plt.plot(X[y==0, 0], X[y==0, 1], "yo", label="Iris-Setosa")

# add labels and plot information
plt.clabel(contour, inline=1, fontsize=12)
plt.xlabel("Petal length", fontsize=14)
plt.ylabel("Petal width", fontsize=14)
plt.legend(loc="center left", fontsize=14)
plt.axis([0, 7, 0, 3.5]);

```