Unidad Práctica 2: Visualización de Tablas¶
En esta práctica seguiremos trabajando con la Encuesta Origen-Destino de Santiago 2012. Exploraremos preguntas cuyas respuestas se obtienen utilizando técnicas de visualización de tablas.
Preámbulo, Carga y Preparación de Datos¶
Esta celda configura la ruta a los datos. Asume dos alternativas: que usas Google Colab, o bien que ejecutas este notebook dentro de la carpeta del repositorio aves.
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from statsmodels.stats.weightstats import DescrStatsW
import matplotlib as mpl
from matplotlib.patches import Patch
# esto configura la calidad de la imagen. dependerá de tu resolución. el valor por omisión es 80
mpl.rcParams["figure.dpi"] = 96
# esto depende de las fuentes que tengas instaladas en el sistema.
mpl.rcParams["font.family"] = "Fira Sans Extra Condensed"
# Importar la biblioteca seaborn para la visualización de datos
import seaborn as sns
# Establecer el estilo de fondo de las gráficas como "whitegrid" en seaborn.
sns.set_style("whitegrid")
def decode_column(
df,
fname,
col_name,
index_col="Id",
value_col=None,
sep=";",
encoding="utf-8",
index_dtype=np.float64,
):
"""
Agrega una columna extra al DataFrame `df` decodificando valores desde un archivo externo.
:param df: DataFrame al que se agregará la columna extra.
:param fname: Nombre del archivo que contiene los valores a decodificar.
:param col_name: Nombre de la columna en el DataFrame `df` que queremos decodificar.
:param index_col: Nombre de la columna en el archivo `fname` que contiene los índices que codifican la columna `col_name`.
:param value_col: Nombre de la columna en el archivo `fname` que tiene los valores decodificados. Si no se proporciona, se utilizará "value" por defecto.
:param sep: Carácter que separa los valores en el archivo `fname`.
:param encoding: Identificación del conjunto de caracteres (character set) que utiliza el archivo. Usualmente es utf-8, si no funciona, se puede probar con iso-8859-1.
:param index_dtype: Tipo de datos del índice en el archivo `fname`, por defecto np.float64.
:return: Serie con los valores decodificados que se agregarán como columna extra en el DataFrame `df`.
"""
if value_col is None:
value_col = "value"
# Lee el archivo `fname` que contiene los valores decodificados junto con sus índices.
values_df = pd.read_csv(
fname,
sep=sep,
index_col=index_col,
names=[index_col, value_col],
header=0,
dtype={index_col: index_dtype},
encoding=encoding,
)
# Extrae la columna `col_name` del DataFrame `df`.
src_df = df.loc[:, (col_name,)]
# Une el DataFrame original con los valores decodificados usando la columna `col_name` como índice.
# Se obtiene una Serie con los valores decodificados que se agregarán como columna extra en el DataFrame `df`.
return src_df.join(values_df, on=col_name)[value_col]
Leeremos la encuesta origen destino. Primero las personas. Le agregaremos dos atributos a la tabla:
Edad: atributo cuantitativo definido como el año 2013 menos el año de nacimiento.GrupoEtareo: atributo ordinal definido como grupos de edad de 5 años. Se calcula a partir deEdadcon la operación módulo.
# leer personas
path = "https://raw.githubusercontent.com/zorzalerrante/aves/master/data/external/EOD_STGO/"
personas = pd.read_csv(
path + "personas.csv",sep=";", decimal=",", encoding="utf-8"
).rename(columns={"Factor": "FactorPersona"})
# agregar atributos
atributos = ["Sexo","TramoIngreso","Relacion","Ocupacion"]
for col_name in atributos:
personas[col_name] = decode_column(personas,path + f"Tablas_parametros/{col_name}.csv", col_name)
personas["Edad"] = 2013 - personas["AnoNac"]
personas["GrupoEtareo"] = personas["Edad"] - (personas["Edad"] % 5)
personas["GrupoEtareo"].value_counts()
20 5348 25 4571 15 4472 50 4248 40 4173 45 4008 30 4004 10 3898 35 3769 5 3694 55 3601 60 2998 0 2953 65 2775 70 2128 75 1423 80 1111 85 590 90 222 95 58 100 9 105 1 Name: GrupoEtareo, dtype: int64
Procedemos a leer las tablas restantes y a crear una tabla con toda la información.
# leer hogares
hogares = pd.read_csv(
path + "Hogares.csv", sep=";", decimal=",", encoding="utf-8"
).rename(columns={"Factor": "FactorHogar"})
# agregar atributos
col_name= "Sector"
hogares[col_name] = decode_column(hogares,path + f"Tablas_parametros/{col_name}.csv", col_name)
# leer viajes
viajes = (
pd.read_csv(path + "viajes.csv", sep=";", decimal=",")
.join(
pd.read_csv(path + "ViajesDifusion.csv", sep=";", index_col="Viaje"),
on="Viaje",
)
.join(
pd.read_csv(path + "DistanciaViaje.csv", sep=";", index_col="Viaje"),
on="Viaje",
)
)
# agregar atributos
viajes["ModoAgregado"] = decode_column(
viajes,path + "Tablas_parametros/ModoAgregado.csv", index_col="ID",value_col="Modo",col_name = "ModoAgregado")
viajes["ModoDifusion"] = decode_column(
viajes,path + "Tablas_parametros/ModoDifusion.csv", encoding="latin-1",index_col="ID",col_name = "ModoDifusion")
viajes["SectorOrigen"] = decode_column(
viajes,path + "Tablas_parametros/Sector.csv", col_name="SectorOrigen",index_col="Sector",value_col="Nombre",sep=";")
viajes["SectorDestino"] = decode_column(
viajes,path + "Tablas_parametros/Sector.csv", col_name="SectorDestino",index_col="Sector",value_col="Nombre",sep=";")
viajes["Proposito"] = decode_column(
viajes,path + "Tablas_parametros/Proposito.csv", col_name="Proposito")
viajes["ComunaOrigen"] = decode_column(
viajes,path + "Tablas_parametros/Comunas.csv","ComunaOrigen",value_col="Comuna",sep=",")
viajes["ComunaDestino"] = decode_column(
viajes,path + "Tablas_parametros/Comunas.csv", "ComunaDestino",value_col="Comuna",sep=",")
viajes["Periodo"] = decode_column(
viajes,path + "Tablas_parametros/Periodo.csv","Periodo",sep=";",value_col="Periodos")
# correciones
viajes = viajes[pd.notnull(viajes["HoraIni"])]
viajes = viajes[viajes["Imputada"] == 0].copy()
viajes["HoraIni"] = pd.to_timedelta(viajes["HoraIni"] + ":00")
tabla_completa = (viajes.merge(personas)).merge(hogares)
tabla_completa.sample(5)
| Hogar | Persona | Viaje | Etapas | ComunaOrigen | ComunaDestino | SectorOrigen | SectorDestino | ZonaOrigen | ZonaDestino | ... | NumVeh | NumBicAdulto | NumBicNino | Propiedad | MontoDiv | ImputadoDiv | MontoArr | ImputadoArr | IngresoHogar | FactorHogar | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 66304 | 217411 | 21741101 | 2174110102 | 2 | Maipú | Pudahuel | Poniente | Poniente | 409 | 517 | ... | 1 | 0 | 0 | 1 | NaN | 0 | 166989 | 1 | 300000 | 88.713348 |
| 29574 | 153240 | 15324004 | 1532400401 | 1 | Colina | Colina | Norte | Norte | 737 | 737 | ... | 0 | 0 | 0 | 5 | NaN | 0 | 135000 | 0 | 850000 | 28.952494 |
| 36325 | 164711 | 16471101 | 1647110102 | 1 | Providencia | Lo Barnechea | Oriente | Oriente | 503 | 331 | ... | 2 | 2 | 2 | 1 | NaN | 0 | 476751 | 1 | 2564470 | 188.567993 |
| 9921 | 117950 | 11795002 | 1179500204 | 1 | El Bosque | El Bosque | Sur | Sur | 114 | 107 | ... | 0 | 0 | 1 | 3 | NaN | 0 | 70000 | 0 | 304136 | 100.737213 |
| 75274 | 235191 | 23519101 | 2351910101 | 2 | Puente Alto | Las Condes | Sur-Oriente | Oriente | 689 | 289 | ... | 0 | 1 | 0 | 1 | NaN | 0 | 100000 | 0 | 1041087 | 82.661583 |
5 rows × 98 columns
En esta clase consideraremos los factores de expansión de la encuesta, que son necesarios para que los análisis sean representativos de la población.
El peso o representatividad de un viaje es la multiplicación de su factor de expansión (qué tan frecuente ese tipo de viaje es) y el del factor de expansión de cada persona (qué tan representativa de otras personas es). Además hay factores de expansión para días de semana, sábado, domingo, y periodos estival (vacaciones) y normal.
Trabajaremos con los periodos normales.
tabla_completa["PesoLaboral"] = (
tabla_completa["FactorLaboralNormal"] * tabla_completa["FactorPersona"]
)
tabla_completa["PesoSabado"] = (
tabla_completa["FactorSabadoNormal"] * tabla_completa["FactorPersona"]
)
tabla_completa["PesoDomingo"] = (
tabla_completa["FactorDomingoNormal"] * tabla_completa["FactorPersona"]
)
¿Cuáles son las rutinas en la ciudad?¶
Para mejorar el funcionamiento de una ciudad es clave entender qué se hace en ella y cuándo.
Sabemos que el qué se hace está codificado en el atributo categórico Proposito. También sabemos que el atributo categórico DiaAsig se refiere al día que está asignado a la persona que responde la encuesta (al día de sus viajes).
tabla_completa["DiaAsig"]
0 jueves
1 jueves
2 jueves
3 jueves
4 jueves
...
100329 domingo
100330 domingo
100331 domingo
100332 domingo
100333 domingo
Name: DiaAsig, Length: 100334, dtype: object
Definiremos una rutina como la distribución de viajes por tipo de propósito en cada unidad de análisis (en este caso, un día).
Utilizaremos operaciones groupby para calcular esa distribución para cada uno de los días de la semana, en periodo normal.
rutina_lunes_a_viernes = (
tabla_completa[pd.notnull(tabla_completa["PesoLaboral"])]
.groupby(["DiaAsig", "Proposito"])["PesoLaboral"]
.sum()
.unstack()
.loc[["lunes", "martes", "miércoles", "jueves", "viernes"]]
)
rutina_lunes_a_viernes
| Proposito | Al estudio | Al trabajo | Buscar o Dejar a alguien | Buscar o dejar algo | Comer o Tomar algo | De compras | De salud | Otra actividad (especifique) | Por estudio | Por trabajo | Recreación | Trámites | Visitar a alguien | volver a casa |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| DiaAsig | ||||||||||||||
| lunes | 163331.009416 | 290370.986587 | 71387.681371 | 7065.785357 | 11484.669371 | 84709.464346 | 34734.490540 | 24812.238886 | 17599.208422 | 35464.711518 | 22193.371789 | 58972.778600 | 24313.804430 | 7.122546e+05 |
| martes | 174439.776426 | 330180.424252 | 83276.907579 | 4720.350549 | 13277.306404 | 83855.135861 | 31484.930397 | 21999.526677 | 17947.370095 | 34045.254491 | 23936.417781 | 66379.161819 | 33555.124037 | 8.012940e+05 |
| miércoles | 199481.973499 | 398741.846543 | 86662.689333 | 8135.059386 | 19718.939423 | 130186.371335 | 54646.989179 | 30977.675729 | 18934.746992 | 62985.848813 | 23428.509000 | 75983.196793 | 53380.677754 | 9.866394e+05 |
| jueves | 182035.108396 | 349612.200228 | 106079.754074 | 6378.063755 | 10274.714329 | 118715.161069 | 40820.227170 | 21734.513481 | 15470.880297 | 42093.461466 | 29229.833235 | 70673.366141 | 44929.371294 | 8.802110e+05 |
| viernes | 223807.953547 | 400178.434211 | 105907.865006 | 8695.953519 | 16059.991523 | 147956.860167 | 40045.598930 | 37040.822368 | 22849.890914 | 50086.145707 | 62658.035583 | 93440.610247 | 66731.634748 | 1.046487e+06 |
rutina_sabado = (
tabla_completa[pd.notnull(tabla_completa["PesoSabado"])]
.groupby(["DiaAsig", "Proposito"])["PesoSabado"]
.sum()
.unstack()
)
rutina_domingo = (
tabla_completa[pd.notnull(tabla_completa["PesoDomingo"])]
.groupby(["DiaAsig", "Proposito"])["PesoDomingo"]
.sum()
.unstack()
)
Ahora uniremos estas tablas para tener una tabla única.
Además, eliminaremos los viajes de volver a casa y de tipo otra actividad porque no ayudan a caracterizar las rutinas diarias.
rutina_semanal = pd.concat(
[rutina_lunes_a_viernes, rutina_sabado, rutina_domingo]
).drop(["volver a casa", "Otra actividad (especifique)"], axis=1)
rutina_semanal
| Proposito | Al estudio | Al trabajo | Buscar o Dejar a alguien | Buscar o dejar algo | Comer o Tomar algo | De compras | De salud | Por estudio | Por trabajo | Recreación | Trámites | Visitar a alguien |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| DiaAsig | ||||||||||||
| lunes | 163331.009416 | 290370.986587 | 71387.681371 | 7065.785357 | 11484.669371 | 84709.464346 | 34734.490540 | 17599.208422 | 35464.711518 | 22193.371789 | 58972.778600 | 24313.804430 |
| martes | 174439.776426 | 330180.424252 | 83276.907579 | 4720.350549 | 13277.306404 | 83855.135861 | 31484.930397 | 17947.370095 | 34045.254491 | 23936.417781 | 66379.161819 | 33555.124037 |
| miércoles | 199481.973499 | 398741.846543 | 86662.689333 | 8135.059386 | 19718.939423 | 130186.371335 | 54646.989179 | 18934.746992 | 62985.848813 | 23428.509000 | 75983.196793 | 53380.677754 |
| jueves | 182035.108396 | 349612.200228 | 106079.754074 | 6378.063755 | 10274.714329 | 118715.161069 | 40820.227170 | 15470.880297 | 42093.461466 | 29229.833235 | 70673.366141 | 44929.371294 |
| viernes | 223807.953547 | 400178.434211 | 105907.865006 | 8695.953519 | 16059.991523 | 147956.860167 | 40045.598930 | 22849.890914 | 50086.145707 | 62658.035583 | 93440.610247 | 66731.634748 |
| sábado | 6632.211399 | 109218.910598 | 20016.534108 | 4518.136226 | 6519.612775 | 205890.072838 | 9716.742966 | 7007.223310 | 11250.685733 | 83467.604697 | 20536.963703 | 68399.114467 |
| domingo | 1382.036374 | 65413.281352 | 19317.983047 | 4856.792429 | 6858.284792 | 207106.972860 | 5123.241740 | 8066.969086 | 12798.502192 | 83617.829194 | 23863.931986 | 89631.140906 |
¿Cómo visualizar esta tabla? Una manera directa es utilizar el método plot de pandas, que usará un linechart. Veamos como luce:
rutina_semanal.plot()
plt.show()
No se ve bonito, pero tampoco configuramos nada del gráfico, solamente lo ejecutamos para tener una noción de cómo se verían los datos.
A pesar de que podríamos utilizar líneas, ya que la progresión de lunes a domingo es ordinal y puede ser interpolada, no tiene un significado relevante para nosotros de acuerdo a la definición de rutina. Además la cantidad de categorías en los datos hace difícil distinguir una línea de otra.
Podemos hacer la misma exploración, esta vez con un barchart:
rutina_semanal.plot(kind="bar")
plt.show()
Esto ya parece ser más manejable para analizar. Utilicemos el barchart de aves para poder configurarlo fácilmente:
def normalize_rows(df):
"""
Normaliza las filas de un DataFrame dividiendo cada valor de la fila por la suma total de la fila.
:param df: DataFrame que se desea normalizar.
:type df: pandas.DataFrame
:return: Un nuevo DataFrame con las filas normalizadas.
"""
return df.div(df.sum(axis=1), axis=0)
def normalize_columns(df):
"""
Normaliza las columnas de un DataFrame dividiendo cada valor de la columna por la suma total de la columna.
:param df: DataFrame que se desea normalizar.
:return: Un nuevo DataFrame con las columnas normalizadas.
"""
return normalize_rows(df.T).T
def barchart(
ax,
df,
palette="plasma",
stacked=False,
normalize=False,
sort_items=False,
sort_categories=False,
fill_na_value=None,
bar_width=0.9,
legend=True,
legend_args=None,
return_df=False,
**kwargs
):
"""
Genera un gráfico de barras en un eje especificado (ax) a partir de un DataFrame (df) en Python.
:param ax: El eje en el que se dibujará el gráfico de barras.
:param df: El DataFrame que contiene los datos para el gráfico de barras.
:param palette: Paleta de colores a utilizar en las barras. Puede ser un nombre de paleta válido o una lista de colores personalizados.
Por defecto es "plasma".
:param stacked: Indica si las barras deben apilarse una encima de otra (True) o si deben colocarse una al lado de la otra (False).
Por defecto es False.
:param normalize: Indica si los valores de las barras deben normalizarse a la suma total (porcentajes).
Por defecto es False.
:param sort_items: Indica si se deben ordenar los ítems (barras) en función de sus valores.
Por defecto es False.
:param sort_categories: Indica si se deben ordenar las categorías (eje x) en función de sus valores.
Por defecto es False.
:param fill_na_value: Valor que se utilizará para rellenar los valores faltantes en el DataFrame.
Por defecto es None.
:param bar_width: Ancho de las barras. Un valor más cercano a 1 creará barras más anchas y cercanas entre sí.
Por defecto es 0.9.
:param legend: Indica si se debe mostrar la leyenda en el gráfico.
Por defecto es True.
:param legend_args: Argumentos adicionales para personalizar la apariencia de la leyenda.
Por defecto es None.
:param return_df: Indica si se debe devolver el DataFrame transformado después de aplicar posibles modificaciones.
Por defecto es False.
:param **kwargs: Argumentos adicionales para personalizar el aspecto de las barras. Estos argumentos serán pasados a la función
de trazado de barras (barplot) de Matplotlib.
:return: Si return_df es True, la función devuelve el DataFrame transformado. De lo contrario, devuelve None.
"""
sns.set_palette(palette, n_colors=len(df.columns))
if fill_na_value is not None:
df = df.fillna(fill_na_value)
if normalize:
df = df.pipe(normalize_rows)
if sort_categories:
sort_values = df.mean(axis=0).sort_values(ascending=False)
df = df[sort_values.index].copy()
if sort_items:
df = df.sort_values(df.columns[0])
df.plot.bar(
ax=ax,
stacked=stacked,
width=bar_width,
edgecolor="none",
legend=legend,
**kwargs
)
if legend:
if legend_args is None:
legend_args = dict(
bbox_to_anchor=(1.0, 0.5), loc="center left", frameon=False
)
handles, labels = map(reversed, ax.get_legend_handles_labels())
ax.legend(handles, labels, **legend_args)
ax.ticklabel_format(axis="y", useOffset=False, style="plain")
sns.despine(ax=ax, left=True)
if normalize:
ax.set_ylim([0, 1])
if return_df:
return df
fig, ax = plt.subplots(figsize=(9, 6))
barchart(ax, rutina_semanal, sort_categories=True, palette='Set2')
Seguimos teniendo muchas categorías, pero al menos podemos comparar sus distribuciones dentro de un día y ver algunos patrones globales, así como comparar la misma categoría a través de varios días.
¿Qué ven ustedes en este gráfico?
Una alternativa es usar una versión apilada, el stacked barchart:
fig, ax = plt.subplots(figsize=(9, 6))
barchart(ax, rutina_semanal, stacked=True, sort_categories=True)
Este gráfico es similar al anterior, sin embargo, al ser apilado permite comparar fácilmente la categoría de base (Al trabajo). En las otras podemos identificar diferencias en aquellas que varían más (por ejemplo, Al estudio y su diferencia entre lunes y viernes con el fin de semana), pero en general nos cuesta ver diferencias dentro de una misma categoría.
Eso sumado a que las barras tienen largos totales diferentes, pues la cantidad de viajes dentro de cada día es distinta. Observamos que el fin de semana hay menos viajes.
Ahora bien, ¿nos interesa esa diferencia?¿O lo que buscamos es identificar patrones relativos? En tal caso, podemos probar con un gráfico normalizado:
fig, ax = plt.subplots(figsize=(9, 6))
barchart(ax, rutina_semanal, stacked=True, normalize=True, sort_categories=True, palette='Set2')
Al usar un gráfico relativo encontramos diferencias que antes no parecían tan notorias. Por ejemplo, en proporción, los viajes de recreación son más frecuentes los fines de semana que de lunes a viernes. En el gráfico absoluto se notaba un ligero incremento, pero quizás lo interesante es que, así como suben los de recreación, bajan mucho los demás.
Lo mismo sucede con visitar a alguien e ir de compras.
El gráfico de barras podría ser suficiente si lo que queremos es determinar si hay diferencias entre las rutinas. Con esta última versión, sabemos que son diferentes, y tenemos una noción de cuáles son las diferencias.
Sin embargo, si nuestra tarea consistiese en identificar elementos específicos de las rutinas, como puede ser conocer los valores exactos de la distribución, o agrupar actividades de acuerdo a su distribución en varios días, entonces debemos buscar otra alternativa.
Exploremos como luce un heatmap en este caso:
import seaborn as sns
sns.heatmap(rutina_semanal)
plt.show()
Lo que hicimos en el gráfico de barras fue normalizar las columnas de la tabla. Podemos hacer lo mismo. Y luego trasponerla para facilitar la lectura. Quedaría así:
sns.heatmap(rutina_semanal.pipe(normalize_columns).T)
plt.show()
Observamos que este heatmap nos permite apreciar las variaciones diarias en la proporción. Si lo configuramos para que muestre más información y tenga mejor apariencia podría ser el gráfico final de la tarea:
fig, ax = plt.subplots(figsize=(9, 6))
sns.heatmap(
rutina_semanal.pipe(normalize_columns).T,
ax=ax,
annot=True,
fmt=".2f",
linewidth=0.5,
)
ax.set_ylabel("")
ax.set_xlabel("")
ax.set_title("Rutinas Diarias en Santiago")
fig.tight_layout()
Ese gráfico ya está terminado: podemos ver patrones globales gracias a la escala de colores, y podemos comparar e identificar valores específicos gracias a las anotaciones.
Todavía nos falta poder agrupar las actividades (o filas de la matriz) de acuerdo a su similitud. Afortunadamente lo podemos lograr cambiando el método empleado: usar un clustermap en vez de un heatmap:
grid = sns.clustermap(
rutina_semanal.pipe(normalize_columns).T,
col_cluster=False,
figsize=(9, 6),
annot=True,
fmt=".2f",
linewidth=0.5,
dendrogram_ratio=[0.1, 0.0],
method="ward"
)
grid.ax_cbar.set_visible(False)
grid.ax_heatmap
grid.ax_heatmap.set_ylabel("")
grid.ax_heatmap.set_xlabel("")
grid.ax_heatmap.set_title("Rutinas Diarias en Santiago")
grid.fig.tight_layout()
Con esto ya damos por resuelta la tarea de entender las rutinas diarias.
Los siguientes son ejercicios propuestos:
- Realizar el mismo cálculo para período estival (requiere calcular los factores de expansión correspondientes).
- Realizar el mismo cálculo para hombres y mujeres por separado.
- Visualizar las diferentes por género y por temporada.
¿Cuáles son los perfiles etáreos asociados a las actividades?¶
Hemos analizado las rutinas diarias, sin embargo, no podemos asumir que todas las personas hacen lo mismo. Una de las dimensiones que podría presentar las diferencias más grandes en la configuración de las rutinas es la edad.
En esta tarea queremos saber si hay tendencias en como distintas edades realizan las actividades que hemos estudiado.
Lo primero que podemos hacer es calcular la tabla de asociación utilizando una operación groupby:
rutina_x_edad = (
tabla_completa.groupby(["GrupoEtareo", "Proposito"])["PesoLaboral"].sum().unstack()
)
rutina_x_edad
| Proposito | Al estudio | Al trabajo | Buscar o Dejar a alguien | Buscar o dejar algo | Comer o Tomar algo | De compras | De salud | Otra actividad (especifique) | Por estudio | Por trabajo | Recreación | Trámites | Visitar a alguien | volver a casa |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| GrupoEtareo | ||||||||||||||
| 0 | 42845.641137 | 447.418443 | 17278.201471 | 458.819947 | 25.105106 | 18021.787343 | 11745.021804 | 6494.619821 | 4909.880583 | NaN | 6683.626741 | 6922.065900 | 13443.436222 | 127572.411334 |
| 5 | 211666.192896 | 638.584542 | 6424.147522 | 618.402344 | 259.629435 | 10380.601513 | 5025.680469 | 3428.100560 | 15179.304869 | NaN | 9714.993789 | 1696.103686 | 10173.413551 | 283033.860317 |
| 10 | 219341.643192 | 439.525230 | 10746.005365 | 490.339247 | 794.708341 | 8626.747498 | 2056.097014 | 2560.097311 | 16785.791015 | 65.873625 | 8700.525588 | 1422.775574 | 8773.531463 | 290355.264648 |
| 15 | 222903.900288 | 20173.788948 | 7588.091555 | 1171.162756 | 2096.077577 | 7776.439153 | 6448.131871 | 3280.679239 | 22004.711692 | 3231.361837 | 16276.490178 | 7926.085263 | 15343.624558 | 330047.529212 |
| 20 | 153552.228086 | 135644.859121 | 17073.170904 | 685.423940 | 4487.416453 | 25883.510725 | 6829.349509 | 8350.148845 | 16643.054401 | 7246.049615 | 15953.114544 | 18850.959560 | 24245.331966 | 382677.399577 |
| 25 | 60258.937374 | 220915.626067 | 36423.248726 | 1082.937472 | 9508.900863 | 37562.197060 | 10261.148343 | 11732.586970 | 11911.965536 | 14141.970709 | 23361.725470 | 17648.072290 | 30050.137867 | 399741.751710 |
| 30 | 14962.215173 | 223509.770478 | 45506.731650 | 5076.295884 | 12571.781491 | 33163.793721 | 8929.908849 | 12325.231355 | 2645.097389 | 30208.079124 | 13830.832589 | 22034.258364 | 19725.207622 | 351676.333985 |
| 35 | 9518.445119 | 219291.908633 | 63497.909600 | 2223.361356 | 9584.806085 | 40365.785714 | 13487.509767 | 9979.645370 | 437.188201 | 27798.977683 | 11848.925609 | 25060.628078 | 11019.842816 | 353796.986308 |
| 40 | 2251.234733 | 213048.059813 | 75690.394083 | 2624.825189 | 5945.397855 | 42627.295039 | 9200.755339 | 8194.053095 | 764.252183 | 18811.466341 | 8467.104393 | 41123.310213 | 7381.595282 | 335004.645585 |
| 45 | 2658.949434 | 201117.819579 | 64460.364127 | 4024.267022 | 4694.249531 | 53098.410645 | 15910.378769 | 11439.746828 | 294.248564 | 23462.804981 | 5262.049222 | 38123.807134 | 14465.013307 | 341536.213429 |
| 50 | 992.755155 | 199880.554879 | 40855.735618 | 5328.991729 | 4081.590144 | 54082.816109 | 18701.109231 | 14880.814069 | 279.941228 | 34474.217853 | 11723.118392 | 37594.156801 | 16688.804472 | 343317.368643 |
| 55 | 1480.344213 | 158536.882727 | 30450.973979 | 4779.461144 | 8012.389318 | 49131.094134 | 16930.725934 | 11736.631224 | 382.268592 | 32270.509714 | 4042.720012 | 25994.172367 | 11636.457272 | 282631.645083 |
| 60 | 477.305334 | 86906.497017 | 12018.541080 | 1227.692333 | 1803.926394 | 57358.664355 | 13223.086294 | 7825.905881 | 116.628654 | 15677.974029 | 4722.645156 | 30139.538667 | 14775.395857 | 191581.353447 |
| 65 | 93.332242 | 49746.935267 | 15260.308279 | 2243.274741 | 4404.183519 | 42295.219568 | 17849.908209 | 8893.810564 | 81.188902 | 13246.825100 | 6535.517782 | 28402.073360 | 10748.818580 | 166513.128641 |
| 70 | 75.181551 | 27972.174182 | 2881.226032 | 159.286079 | 1518.163581 | 33584.275796 | 15411.433050 | 5657.938206 | 171.685026 | 2379.972455 | 2995.675673 | 19258.000467 | 5604.094991 | 97492.982257 |
| 75 | 17.515356 | 7369.460371 | 3289.411513 | 2315.630143 | 541.895102 | 23322.434828 | 13497.909577 | 5743.619117 | 157.556950 | 1056.694876 | 5408.890859 | 21996.177603 | 4530.277713 | 75452.938658 |
| 80 | NaN | 3343.617534 | 3041.991758 | 460.911626 | 445.143021 | 20258.129450 | 11604.932563 | 3026.734230 | 37.332935 | 447.232945 | 4085.647920 | 15841.070980 | 3332.117081 | 52739.251373 |
| 85 | NaN | 87.425864 | 269.853743 | 24.129615 | 40.257232 | 5878.449598 | 3398.019622 | 855.014005 | NaN | 155.411108 | 1779.957553 | 4096.341019 | 881.464749 | 16499.581798 |
| 90 | NaN | 12.983127 | 547.761817 | NaN | NaN | 1546.905764 | 1221.130002 | 0.000000 | NaN | NaN | 52.605918 | 1254.182215 | 92.046893 | 4646.888103 |
| 95 | NaN | NaN | 10.828543 | NaN | NaN | 458.434763 | 0.000000 | 159.400453 | NaN | NaN | NaN | 65.334060 | 0.000000 | 567.887440 |
Tenemos como índice de la tabla un valor cuantitativo y queremos ver las tendencias de cada atributo cuantitativo. Podemos utilizar un linechart para ello. Como sabemos que son muchas actividades, esta vez configuraremos el gráfico de inmediato para que luzca bien: graficaremos cada actividad por separado utilizando la configuración de los subgráficos (subplots).
Este código requiere entender aspectos más avanzados de matplotlib:
fig_ancho = 6
fig_alto = 1
fig, axes = plt.subplots(
len(rutina_x_edad.columns),
1,
figsize=(fig_ancho, len(rutina_x_edad.columns) * fig_alto),
sharex=True,
)
for col, ax in zip(rutina_x_edad.columns, axes):
rutina_x_edad[col].plot(ax=ax, kind="line", color="#FFB7C5", linewidth=2)
ax.set_title(col)
sns.despine(ax=ax)
ax.set_xlim([0, 100])
ax.set_xticks(range(0, 101, 10))
ax.set_xlabel("Edad")
ax.set_ylabel("# Viajes")
fig.align_ylabels()
fig.tight_layout()
Observamos que con esta configuración es directo saber para cada actividad cuál es su asociación con cada grupo etáreo. También sabemos cuando hay tendencias de subida o de bajada. Es un gráfico directo de hacer que nos permite analizar cada actividad por separado, pero también comparar entre ellas, ya que comparten el mismo eje x de referencia.
Ahora bien, nos preocupa que el código se complejiza al configurarlo.
Podemos utilizar seaborn para configurar de manera más sencilla y flexible el gráfico. Por ejemplo, podemos configurarlo para que utilice dos columnas de gráficos en vez de solo una. Eso también lo podemos hacer con el código anterior, pero el cambio en el código escrito sería considerable y requiere conocer parte del funcionamiento interno de matplotlib.
Antes de hacerlo necesitamos convertir la tabla que tiene forma de matriz a una tabla en formato "largo", ya que las funciones de grilla de seaborn utilizan este formato. Se hace así:
rutina_x_edad_longform = rutina_x_edad.stack().rename("n_viajes").reset_index()
rutina_x_edad_longform.sample(5)
| GrupoEtareo | Proposito | n_viajes | |
|---|---|---|---|
| 186 | 65 | De salud | 17849.908209 |
| 108 | 35 | Visitar a alguien | 11019.842816 |
| 244 | 85 | Trámites | 4096.341019 |
| 50 | 15 | Recreación | 16276.490178 |
| 168 | 60 | Buscar o Dejar a alguien | 12018.541080 |
Noten que la diferencia es que cada fila de la tabla contiene un par edad/propósito y el valor correspondiente. Es como si cada celda de la matriz original tuviese su propia fila.
El código de seaborn:
grid = sns.FacetGrid(
rutina_x_edad_longform,
col="Proposito",
col_wrap=2,
aspect=6,
height=1,
sharey=False,
)
grid.map(plt.plot, "GrupoEtareo", "n_viajes", color="#FFB7C5", linewidth=2)
sns.despine()
grid.set(xlim=[0, 100])
grid.set_xlabels("Edad")
grid.set_ylabels("# Viajes")
grid.set(xticks=range(0, 101, 10))
grid.fig.align_ylabels()
grid.tight_layout()
plt.show()
Observando este gráfico notamos cosas como:
- El aumento de los viajes de salud con la edad hasta los 65 años. Después comienzan a disminuir (recordemos que estamos midiendo viajes absolutos).
- La mayor cantidad de personas que sale a comer o tomar algo tiene 30 años.
- Los viajes de buscar o dejar a alguien alcanzan su valor máximo a los 40 años y luego decaen. Posiblemente a los 40 les hijes ya están grandecites para que los vayan a dejar a algún lado.
- ¿Qué más observan ustedes?
Calculemos la versión normalizada por edad. Recordemos que en la tarea anterior normalizamos la matriz por columnas, porque nos interesaba la distribución de días por actividad. Ahora nos interesa la distribución de actividades por edad, por tanto, normalizaremos por filas. El código para la visualización es el mismo excepto por el parámetro de datos de FacetGrid (esta vez la transformación a tabla larga la hacemos en la misma línea de código):
grid = sns.FacetGrid(
rutina_x_edad.pipe(normalize_rows).stack().rename("n_viajes").reset_index(),
col="Proposito",
col_wrap=2,
aspect=6,
height=1,
sharey=False,
)
grid.map(plt.plot, "GrupoEtareo", "n_viajes", color="#FFB7C5", linewidth=2)
sns.despine()
grid.set(xlim=[0, 100])
grid.set_xlabels("Edad")
grid.set_ylabels("# Viajes")
grid.set(xticks=range(0, 101, 10))
grid.fig.align_ylabels()
grid.tight_layout()
plt.show()
En este gráfico observamos que algunos patrones absolutos se mantienen (como las distribuciones de al estudio y al trabajo), pero otros patrones adquieren una forma más ad-hoc a nuestras expectativas. Por ejemplo, los viajes de salud aumentan progresivamente con la edad. Solamente caen en el último grupo etáreo, sin embargo, la cantidad de viajes de ese grupo es tan pequeña que no podemos sacar conclusiones.
¿Qué otros patrones ven ustedes?
Problema propuesto:
- Estudiar el uso de modo de transporte (columna
ModoDifusion) por grupo etáreo.
¿Cuál es la distribución de distancias recorridas?¶
La última tarea de este notebook busca comprender las diferencias en las distribuciones de las distancias recorridas en los viajes al trabajo. Si consideramos la ubicación de los sectores laborales en Santiago, las personas de sectores más periféricos de la ciudad recorran distancias más grandes que la gente que vive, por ejemplo, en el centro. Pero no toda la población trabaja en esos ejes laborales. Por tanto, es de interés conocer la distribución de las distancias recorridas.
La encuesta incluye la variable DistManhattan que representa la distancia recorrida a través de dos líneas en un viaje, una horizontal y una vertical. Veamos su estadística descriptiva:
tabla_completa['DistManhattan'].describe().astype(int)
count 100334 mean 8034 std 16064 min -1 25% 1110 50% 4054 75% 11038 max 1413673 Name: DistManhattan, dtype: int32
De partida observamos que hay valores inválidos (distancias con valor -1) y también valores que se salen de lo común (1400 kilómetros). Los propósitos de esos viajes son los siguientes:
tabla_completa[tabla_completa['DistManhattan'] < 0][['Proposito', 'ComunaOrigen', 'ComunaDestino', 'ModoDifusion']].sample(10)
| Proposito | ComunaOrigen | ComunaDestino | ModoDifusion | |
|---|---|---|---|---|
| 72826 | Otra actividad (especifique) | Puente Alto | Puente Alto | Caminata |
| 59842 | volver a casa | NaN | Peñalolén | Bip! |
| 42018 | Recreación | Maipú | NaN | Auto |
| 86703 | volver a casa | San Bernardo | San Bernardo | Caminata |
| 42793 | volver a casa | Maipú | Maipú | Caminata |
| 99245 | volver a casa | Huechuraba | Huechuraba | Caminata |
| 76160 | Al trabajo | Quilicura | NaN | Auto |
| 63823 | volver a casa | Pudahuel | Pudahuel | Caminata |
| 85678 | De compras | San Bernardo | San Bernardo | Caminata |
| 87362 | De compras | San Bernardo | San Bernardo | Caminata |
tabla_completa[tabla_completa['DistManhattan'] > 1000000][['Proposito', 'ComunaOrigen', 'ComunaDestino', 'SectorDestino', 'ModoDifusion']]
| Proposito | ComunaOrigen | ComunaDestino | SectorDestino | ModoDifusion | |
|---|---|---|---|---|---|
| 4825 | Otra actividad (especifique) | Estación Central | NaN | Exterior a RM | Otros |
| 87077 | Al trabajo | San Bernardo | NaN | Exterior a RM | Otros |
| 91669 | Al trabajo | San Ramón | NaN | Exterior a RM | Bip! - Otros Público |
Notamos que los viajes con distancia muy grande pueden ser válidos. Por ejemplo, es gente que tuvo que hacer un viaje a su trabajo fuera de Santiago y viajaron en avión.
Los viajes con distancia -1 parecieran ser principalmente dentro de la misma comuna y en modos de transporte privado. Debido a su valor negativo es mejor descartarlos de la muestra.
Para entender las distribuciones utilizaremos un boxplot. Como vimos en clase, este tipo de gráfico utiliza percentiles y medianas, por lo que los valores extremos de la distribución no tienen una influencia relevante en el resultado (tres valores extremos de distancia no cambiarán la mediana).
Utilizaremos el boxplot de aves porque permite considerar el factor de expansión. Primero, veamos las distribuciones de acuerdo al sector de la ciudad donde viven las personas. Al ser pocas categorías nos permitirán entender como funciona la técnica:
def categorical_color_legend(
ax, color_list, labels, loc="best", n_columns=None, **kwargs
):
legend_elements = []
for label, color in zip(labels, color_list):
legend_elements.append(Patch(facecolor=color, edgecolor="none", label=label))
if n_columns is not None:
if type(n_columns) != int:
n_columns = len(color_list)
else:
n_columns = 1
artist = ax.legend(
handles=legend_elements, loc=loc, frameon=False, ncol=n_columns, **kwargs
)
ax.add_artist(artist)
return artist
def boxplot_stats(values: pd.Series, weights: pd.Series, label=None):
w = DescrStatsW(values, weights)
w_quants = w.quantile([0.25, 0.5, 0.75])
w_iqr = w_quants[0.75] - w_quants[0.25]
w_whisker_low = w_quants[0.25] - 1.5 * w_iqr
w_whisker_high = w_quants[0.75] + 1.5 * w_iqr
w_fliers = values[~values.between(w_whisker_low, w_whisker_high)]
if values.min() > w_whisker_low:
w_whisker_low = values.min()
if values.max() < w_whisker_high:
w_whisker_high = values.max()
result = {
"med": w_quants[0.5],
"q1": w_quants[0.25],
"q3": w_quants[0.75],
"whislo": w_whisker_low,
"whishi": w_whisker_high,
"fliers": w_fliers,
}
if label is not None:
result["label"] = label
return pd.Series(result)
def boxplot(
ax,
df: pd.DataFrame,
group_column: str,
value_column: str,
weight_column: str,
hue_column=None,
sort_by_value=False,
sort_ascending=True,
hue_order=None,
vert=True,
showfliers=False,
palette="Set2",
hue_legend=False,
boxplot_kwargs={},
legend_kwargs={},
):
if not "boxprops" in boxplot_kwargs:
boxplot_kwargs["boxprops"] = {}
if not "medianprops" in boxplot_kwargs:
boxplot_kwargs["medianprops"] = dict(color="black")
if not "flierprops" in boxplot_kwargs:
boxplot_kwargs["flierprops"] = dict(
color="#abacab", marker=".", markersize="1", alpha=0.5
)
if hue_column is None:
if not "facecolor" in boxplot_kwargs["boxprops"]:
boxplot_kwargs["boxprops"]["facecolor"] = "#efefef"
grouped = df.groupby(group_column).apply(
lambda x: boxplot_stats(
x[value_column], x[weight_column], x[group_column].values[0]
)
)
if sort_by_value:
grouped = grouped.sort_values("med", ascending=sort_ascending)
grouped = grouped.to_dict(orient="records")
ax.bxp(
grouped,
showfliers=showfliers,
vert=vert,
patch_artist=True,
**boxplot_kwargs
)
else:
if hue_order is None:
hue_values = list(reversed(df[hue_column].unique()))
else:
hue_values = hue_order
colors = sns.color_palette(palette, n_colors=len(hue_values))
grouped = df.groupby([hue_column, group_column]).apply(
lambda x: boxplot_stats(x[value_column], x[weight_column])
)
order = None
categories = df[group_column].unique()
width = 0.95 / len(hue_values)
positions = (
np.arange(len(categories)) + np.repeat(2 * width, len(categories)).cumsum()
)
offset = np.linspace(width * 0.25, 1 - width * 0.25, len(hue_values))
offset -= offset.mean()
# print(width, offset, positions)
for i, hue in enumerate(hue_values):
boxplot_kwargs["boxprops"]["facecolor"] = colors[i]
group = grouped.loc[hue]
if order is None:
if sort_by_value:
group = group.sort_values("med", ascending=sort_ascending)
order = list(group.index.values)
if len(order) != len(categories):
order.extend([c for c in categories if not c in order])
# enforces the order and inserts potentially missing rows
group = pd.DataFrame(index=order).join(group, how="left").copy()
for j, category in enumerate(order):
if not category in group.index:
continue
records = group.loc[category].to_dict()
ax.bxp(
[records],
positions=[positions[j] - offset[i]],
showfliers=showfliers,
vert=vert,
patch_artist=True,
widths=[width],
**boxplot_kwargs
)
if vert:
ax.set_xticks(positions)
ax.set_xticklabels(order)
ax.set_xlim([-offset[0], len(categories) + offset[-1]])
ax.set_xlim(
[
positions[0] + offset[0] - width * 1.5,
positions[-1] + offset[-1] + width * 1.5,
]
)
else:
ax.set_yticks(positions)
ax.set_yticklabels(order)
ax.set_ylim(
[
positions[0] + offset[0] - width * 1.5,
positions[-1] + offset[-1] + width * 1.5,
]
)
if hue_legend:
categorical_color_legend(ax, colors, hue_values, **legend_kwargs)
tabla_filtrada = tabla_completa[(tabla_completa['DistManhattan'] > 0) & (tabla_completa['Proposito'] == 'Al trabajo')]
fig, ax = plt.subplots()
boxplot(
ax,
tabla_filtrada,
"Sector", # atributo categórico que compararemos
"DistManhattan", # atributo cuantitativo
"PesoLaboral" # factor de expansión
)
fig.tight_layout()
Podemos configurar la apariencia del gráfico para que sea más fácil de leer:
fig, ax = plt.subplots()
boxplot(
ax,
tabla_filtrada,
"Sector",
"DistManhattan",
"PesoLaboral",
vert=False,
sort_by_value=True,
showfliers=False,
boxplot_kwargs=dict(boxprops={"facecolor": "#FFB7C5"}),
)
sns.despine(ax=ax, left=True)
ax.set_xlabel("Distancia [m]")
fig.tight_layout()
Observamos que:
- El sector centro tiene la menor mediana y el menor percentil del 75%. Es el sector con menor variabilidad.
- El sector oriente es el segundo en cercanía al trabajo y en menor variabilidad.
- El sector con mayor variabilidad y mayores distancias es externo al radio urbano.
Ahora veamos las diferencias de acuerdo al modo de transporte utilizado:
fig, ax = plt.subplots(figsize=(9, 9))
boxplot(
ax,
tabla_completa,
"ModoDifusion",
"DistManhattan",
"PesoLaboral",
hue_column="Sexo",
hue_legend=True,
vert=False,
sort_by_value=True,
palette="PuOr",
showfliers=False,
)
sns.despine(ax=ax, left=True)
ax.set_xlabel("Distancia [m]")
ax.set_title("Distancia al Trabajo por Modo de Transporte y Sexo")
fig.tight_layout()
Observamos lo siguiente:
- En general, las mujeres recorren una distancia menor en sus viajes al trabajo, en casi todo modo de transporte (o combinación de modos). Existen varias explicaciones para esto, una de ellas es que necesitan recorrer menos distancia porque deben cumplir roles en el hogar que los hombres no asumen.
- Los viajes en transporte público son más largos que los viajes en auto.
- Hay viajes en bicicleta que son más largos que viajes en auto. Si queremos descontaminar la ciudad una manera de hacerlo es incentivar que viajes cortos en auto se hagan en modos alternativos.
¿Qué ven ustedes?
Problema propuestos:
- Realizar un ejercicio similar para los tiempos de viaje. Motivación: un viaje en auto y un viaje en transporte público pueden tener tiempos muy distintos a pesar de cubrir la misma distancia.