Números

Introducción

En este capítulo aprenderás herramientas útiles para crear y manipular vectores numéricos. Empezaremos viendo con un poco más de detalle .count() antes de adentrarnos en varias transformaciones numéricas. Después aprenderás transformaciones más generales que se pueden aplicar a otros tipos de columna, pero que se usan a menudo con columnas numéricas. Luego conocerás algunos resúmenes útiles más.

Requisitos previos

Este capítulo usa sobre todo funciones de pandas, que probablemente ya tengas instalado, aunque puedes instalarlo con uv add pandas en la terminal. Usaremos ejemplos reales de nycflights13, así como ejemplos de juguete hechos con datos ficticios.

Primero carguemos los datos de vuelos de NYC

import pandas as pd

url = "https://raw.githubusercontent.com/byuidatascience/data4python4ds/master/data-raw/flights/flights.csv"
flights = pd.read_csv(url)

Conteos

Es sorprendente cuánta ciencia de datos puedes hacer solo con conteos y un poco de aritmética básica, por eso pandas se esfuerza en que contar sea lo más fácil posible con .count() y .value_counts(). El primero simplemente da un conteo directo de todos los elementos que no son NA:

flights["dest"].count()
np.int64(336776)

El segundo da un conteo desglosado por tipo:

flights["dest"].value_counts()
dest
ORD    17283
ATL    17215
LAX    16174
BOS    15508
MCO    14082
       ...  
MTJ       15
SBN       10
ANC        8
LEX        1
LGA        1
Name: count, Length: 105, dtype: int64

Esto se ordena automáticamente empezando por la categoría más común. Puedes hacer el mismo cálculo “a mano” con group_by(), agg() y luego la función de conteo. Esto es útil porque te permite calcular otros resúmenes al mismo tiempo:

(
    flights.groupby(["dest"])
    .agg(
        mean_delay=("dep_delay", "mean"),
        count_flights=("dest", "count"),
    )
    .sort_values(by="count_flights", ascending=False)
)
mean_delay count_flights
dest
ORD 13.570484 17283
ATL 12.509824 17215
LAX 9.401344 16174
BOS 8.730613 15508
MCO 11.275998 14082
... ... ...
MTJ 17.642857 15
SBN 21.100000 10
ANC 12.875000 8
LGA NaN 1
LEX -9.000000 1

105 rows × 2 columns

Ten en cuenta que un conteo ponderado no es más que una suma. Por ejemplo, podrías “contar” el número de millas que voló cada avión:

(flights.groupby("tailnum").agg(miles=("distance", "sum")))
miles
tailnum
D942DN 3418
N0EGMQ 250866
N10156 115966
N102UW 25722
N103US 24619
... ...
N997DL 54669
N998AT 15432
N998DL 66052
N999DN 54623
N9EAMQ 167317

4043 rows × 1 columns

Puedes contar los valores faltantes combinando sum() e isnull(). En el dataset de vuelos, esto representa los vuelos cancelados. Ten en cuenta que, como no existe un nombre sencillo en forma de string para aplicar .isnull() seguido de .sum() (a diferencia de ejecutar solo sum(), que vendría dado por el string “sum”), necesitamos usar una función lambda a continuación:

(flights.groupby("dest").agg(n_cancelled=("dep_time", lambda x: x.isnull().sum())))
n_cancelled
dest
ABQ 0
ACK 0
ALB 20
ANC 0
ATL 317
... ...
TPA 59
TUL 16
TVC 5
TYS 52
XNA 25

105 rows × 1 columns

Transformaciones numéricas

Las funciones de transformación producen una salida de la misma longitud que la entrada. La gran mayoría de las funciones de transformación vienen integradas en Python o en el paquete numérico numpy. No es práctico enumerar todas las transformaciones numéricas posibles, así que esta sección mostrará las más útiles.

La aritmética básica se realiza con + (suma), - (resta), * (multiplicación), / (división), ** (potencias), % (módulo) y @ (producto tensorial). La mayoría de estas funciones no necesitan mucha explicación porque ya te resultarán familiares (y puedes consultar las demás cuando las necesites).

Cuando tienes dos columnas numéricas de igual longitud y las sumas o restas, es bastante obvio lo que va a pasar. Pero sí debemos hablar de qué ocurre cuando interviene una variable que no es tan larga como la columna. Esto es importante para operaciones como flights.assign(air_time = air_time / 60), porque hay 336.776 números a la izquierda de / pero solo uno a la derecha. En este caso, pandas entenderá que quieres dividir todos los valores de air time entre 60. A esto a veces se le llama ‘broadcasting’. A continuación hay un diagrama que intenta explicar lo que sucede:

Puedes saber mucho más sobre broadcasting en la documentación de numpy. pandas está construido sobre numpy y hereda parte de su funcionalidad.

Al operar con dos columnas, pandas compara sus formas elemento a elemento. Dos columnas son compatibles cuando son iguales o cuando una de ellas es un escalar. Si no se cumplen estas condiciones, obtendrás un error.

Mínimo y máximo

Las funciones aritméticas hacen lo que esperarías.

flights["distance"].max()
np.int64(4983)

A veces querrás ver el valor máximo o mínimo a lo largo de filas o columnas. Como suele ocurrir con pandas, puedes indicar si una función se aplica a filas o columnas pasándole axis=0 (índice) o axis=1 (columnas). La designación del eje puede ser confusa: recuerda que estás indicando sobre qué dimensión quieres agregar, y te queda la otra dimensión. Así que si queremos encontrar el mínimo en cada fila, agregamos / colapsamos las columnas, por lo que necesitamos pasar axis=1.

df = pd.DataFrame({"x": [1, 5, 7], "y": [3, 2, pd.NA]})
df
x y
0 1 3
1 5 2
2 7 <NA>

Ahora busquemos el mínimo por fila:

df.min(axis=1)
0    1
1    2
2    7
dtype: object

Aritmética modular

La aritmética modular es el nombre técnico del tipo de matemáticas que haces con números enteros, es decir, la división que da como resultado un número entero y un resto. En Python, // hace la división entera y % calcula el resto:

print([x for x in range(1, 11)])
print("divided by 3 gives")
print("remainder:")
print([x % 3 for x in range(1, 11)])
print("divisions:")
print([x // 3 for x in range(1, 11)])
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
divided by 3 gives
remainder:
[1, 2, 0, 1, 2, 0, 1, 2, 0, 1]
divisions:
[0, 0, 1, 1, 1, 2, 2, 2, 3, 3]

La aritmética modular resulta práctica para el dataset de vuelos, porque podemos usarla para descomponer la variable sched_dep_time en hour y minute:

flights.assign(
    hour=lambda x: x["sched_dep_time"] // 100,
    minute=lambda x: x["sched_dep_time"] % 100,
)
year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time arr_delay carrier flight tailnum origin dest air_time distance hour minute time_hour
0 2013 1 1 517.0 515 2.0 830.0 819 11.0 UA 1545 N14228 EWR IAH 227.0 1400 5 15 2013-01-01T10:00:00Z
1 2013 1 1 533.0 529 4.0 850.0 830 20.0 UA 1714 N24211 LGA IAH 227.0 1416 5 29 2013-01-01T10:00:00Z
2 2013 1 1 542.0 540 2.0 923.0 850 33.0 AA 1141 N619AA JFK MIA 160.0 1089 5 40 2013-01-01T10:00:00Z
3 2013 1 1 544.0 545 -1.0 1004.0 1022 -18.0 B6 725 N804JB JFK BQN 183.0 1576 5 45 2013-01-01T10:00:00Z
4 2013 1 1 554.0 600 -6.0 812.0 837 -25.0 DL 461 N668DN LGA ATL 116.0 762 6 0 2013-01-01T11:00:00Z
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
336771 2013 9 30 NaN 1455 NaN NaN 1634 NaN 9E 3393 NaN JFK DCA NaN 213 14 55 2013-09-30T18:00:00Z
336772 2013 9 30 NaN 2200 NaN NaN 2312 NaN 9E 3525 NaN LGA SYR NaN 198 22 0 2013-10-01T02:00:00Z
336773 2013 9 30 NaN 1210 NaN NaN 1330 NaN MQ 3461 N535MQ LGA BNA NaN 764 12 10 2013-09-30T16:00:00Z
336774 2013 9 30 NaN 1159 NaN NaN 1344 NaN MQ 3572 N511MQ LGA CLE NaN 419 11 59 2013-09-30T15:00:00Z
336775 2013 9 30 NaN 840 NaN NaN 1020 NaN MQ 3531 N839MQ LGA RDU NaN 431 8 40 2013-09-30T12:00:00Z

336776 rows × 19 columns

Logaritmos

Los logaritmos son una transformación increíblemente útil para tratar datos que abarcan varios órdenes de magnitud. También convierten el crecimiento exponencial en crecimiento lineal. Por ejemplo, piensa en el interés compuesto: la cantidad de dinero que tienes en year + 1 es la cantidad que tenías en year multiplicada por la tasa de interés. Eso da una fórmula como money = starting * interest ** year:

import numpy as np

starting = 100
interest = 1.05
money = pd.DataFrame(
    {"year": 2000 + np.arange(1, 51), "money": starting * interest ** np.arange(1, 51)}
)
money.head()
year money
0 2001 105.000000
1 2002 110.250000
2 2003 115.762500
3 2004 121.550625
4 2005 127.628156

Si graficas estos datos, obtendrás una curva exponencial:

money.plot(x="year", y="money");

Aplicar una transformación logarítmica al eje y da una línea recta:

money.plot(x="year", y="money", logy=True);

Es una línea recta porque log(money) = log(starting) + n * log(interest) sigue el patrón de una recta, y = m * x + b. Es un patrón útil: si ves una línea (aproximadamente) recta tras aplicar el logaritmo al eje y, sabes que hay un crecimiento exponencial subyacente.

Si vas a aplicar una transformación logarítmica a tus datos, numpy te ofrece muchos logaritmos, pero hay tres que usarás con frecuencia: suponiendo que lo hayas importado con import numpy as np, tienes np.log() (el logaritmo natural, base e), np.log2() (base 2) y np.log10() (base 10).

La inversa de log() es np.exp(); para calcular la inversa de np.log2() o np.log10() tendrás que usar 2** o 10**.

Redondeo

Para redondear a un número concreto de decimales, usa .round(n), donde n es el número de decimales al que quieres redondear.

money.head().round(2)
year money
0 2001 105.00
1 2002 110.25
2 2003 115.76
3 2004 121.55
4 2005 127.63

Esto también se puede aplicar a columnas individuales o de forma diferenciada a cada columna mediante un diccionario:

money.head().round({"year": 0, "money": 1})
year money
0 2001 105.0
1 2002 110.2
2 2003 115.8
3 2004 121.6
4 2005 127.6

.round(n) redondea al 10**(-n) más cercano, así que n = 2 redondeará al 0,01 más cercano. Esta definición es útil porque implica que .round(-2) redondeará a la centena más cercana, que es justo lo que hace:

money.tail().round({"year": 0, "money": -2})
year money
45 2046 900.0
46 2047 1000.0
47 2048 1000.0
48 2049 1100.0
49 2050 1100.0

A veces querrás redondear según cifras significativas en lugar de decimales. No hay una forma realmente sencilla de hacerlo, pero puedes definir una función personalizada. Aquí tienes un ejemplo de redondeo a 2 cifras significativas (cambia el 2 de abajo para redondear a otro número de cifras significativas):

money["money"].head().apply(lambda x: float(f'{float(f"{x:.2g}"):g}'))
0    100.0
1    110.0
2    120.0
3    120.0
4    130.0
Name: money, dtype: float64

Si tienes un array o una lista de números fuera de un dataframe, puedes usar la función de numpy

np.round([1.5, 2.5, 1.4])
array([2., 2., 1.])

numpy también tiene los métodos .floor() y .ceil()

real_nums = 100 * np.random.random(size=10)
real_nums
array([92.44790868, 82.67434516,  3.13025344, 93.50201546, 51.23614314,
       27.73877732, 37.38200529, 42.44289822, 75.58118711, 89.20065315])
np.ceil(real_nums)
array([93., 83.,  4., 94., 52., 28., 38., 43., 76., 90.])
np.floor(real_nums)
array([92., 82.,  3., 93., 51., 27., 37., 42., 75., 89.])

Recuerda que siempre puedes aplicar funciones de numpy a columnas de un dataframe de pandas así:

money["money"].head().apply(np.ceil)
0    105.0
1    111.0
2    116.0
3    122.0
4    128.0
Name: money, dtype: float64

Agregados acumulativos y móviles

pandas tiene varias funciones acumulativas, como .cumsum(), .cummax() y .cummin(), y .cumprod().

money["money"].tail().cumsum()
45     943.425818
46    1934.022928
47    2974.149892
48    4066.283205
49    5213.023184
Name: money, dtype: float64

Como siempre, esto también se puede aplicar a lo largo de las filas pasando axis=1.

Transformaciones generales

Las siguientes secciones describen algunas transformaciones generales que se usan a menudo con vectores numéricos, pero que se pueden aplicar a todos los demás tipos de columna.

Ranking

La función de ranking de pandas es .rank(). Volvamos a los datos que creamos antes y asignémosles un ranking:

df
x y
0 1 3
1 5 2
2 7 <NA>
df.rank()
x y
0 1.0 2.0
1 2.0 1.0
2 3.0 NaN

¡Por supuesto, aquí no hay cambios porque los elementos ya estaban ordenados! También podemos obtener un ranking porcentual pasando el argumento con nombre pct=True.

df.rank(pct=True)
x y
0 0.333333 1.0
1 0.666667 0.5
2 1.000000 NaN

Desfases y desplazamientos

Los desfases te permiten ‘deslizar’ columnas hacia arriba o hacia abajo respecto de su posición original, es decir, desplazar su ubicación respecto del índice. La función que lo hace se llama shift() y produce adelantos (leads) o retardos (lags) según uses valores positivos o negativos, respectivamente. Recuerda que un adelanto desplazará el patrón de los datos hacia la izquierda cuando se grafique frente al índice, mientras que un retardo desplazará los patrones hacia la derecha. Los adelantos y retardos son especialmente útiles para datos de series temporales (que aún no hemos visto).

Veamos un ejemplo de desfases con el dataframe money de antes:

money["money_lag_5"] = money["money"].shift(5)
money["money_lead_10"] = money["money"].shift(-10)
money.set_index("year").plot();

Ejercicios

  1. Encuentra los 10 vuelos con más retraso usando una función de ranking.

  2. ¿Qué avión ("tailnum") tiene el peor historial de puntualidad?

  3. ¿A qué hora del día deberías volar si quieres evitar los retrasos lo máximo posible?

  4. Para cada destino, calcula el total de minutos de retraso. Para cada vuelo, calcula la proporción que representa del retraso total de su destino.

  5. Los retrasos suelen estar correlacionados en el tiempo: incluso una vez resuelto el problema que causó el retraso inicial, los vuelos posteriores se retrasan para que puedan salir los anteriores. Usando .shift(), explora cómo se relaciona el retraso medio de los vuelos de una hora con el retraso medio de la hora anterior.

  6. Observa cada destino. ¿Puedes encontrar vuelos sospechosamente rápidos? (es decir, vuelos que representen un posible error de captura de datos). Calcula el tiempo en el aire de un vuelo en relación con el vuelo más corto a ese destino. ¿Qué vuelos sufrieron más retraso en el aire?

  7. Encuentra todos los destinos a los que vuelan al menos dos aerolíneas. Usa esos destinos para elaborar una clasificación relativa de las aerolíneas según su desempeño en un mismo destino.

Más estadísticas descriptivas útiles

Ya hemos visto lo útiles que pueden ser .mean(), .count() y .value_counts() para el análisis. Sin embargo, pandas tiene muchas más funciones integradas de estadísticas descriptivas. Entre ellas están .median() (puede resultarte interesante comparar la media con la mediana al observar el retraso de salida por hora en los datos de vuelos), .mode(), .min() y .max().

La función .quantile proporciona una clase de estadísticas descriptivas útiles; .quantile(0.5) equivale a median. El cuantil al x% es el valor por debajo del cual se encuentra el x% de los valores. (Ten en cuenta que, con esta definición, .quantile(1) será lo mismo que .max()). Veamos un ejemplo con el percentil 25.

money["money"].quantile(0.25)
np.float64(190.92197566022773)

A veces no quieres solo un percentil, sino varios. pandas lo hace muy fácil al permitirte pasar una lista de cuantiles:

money["money"].quantile([0, 0.25, 0.5, 0.75])
0.00    105.000000
0.25    190.921976
0.50    347.101381
0.75    630.945970
Name: money, dtype: float64

Dispersión

A veces no te interesa tanto dónde se concentra la mayor parte de los datos, sino cómo se dispersan. Dos medidas de uso común son la desviación estándar, .std(), y el rango intercuartílico, que puedes calcular a partir de los cuantiles correspondientes, es decir, es el cuantil al 75% menos el cuantil al 25%, y te da el rango que contiene el 50% central de los datos.

Podemos usar esto para revelar una pequeña rareza en los datos de vuelos. Cabría esperar que la dispersión de la distancia entre origen y destino fuera cero, ya que los aeropuertos siempre están en el mismo lugar. Pero el código siguiente da la impresión de que un aeropuerto, EGE, podría haberse movido.

(
    flights.groupby(["origin", "dest"])
    .agg(
        distance_sd=("distance", lambda x: x.quantile(0.75) - x.quantile(0.25)),
        count=("distance", "count"),
    )
    .query("distance_sd > 0")
)
distance_sd count
origin dest
EWR EGE 1.0 110
JFK EGE 1.0 103

Distribuciones

Conviene recordar que todas las estadísticas descriptivas anteriores son una forma de reducir la distribución a un único número. Esto significa que son fundamentalmente reductivas y, si eliges la medida equivocada, puedes pasar por alto fácilmente diferencias importantes entre grupos. Por eso siempre es buena idea visualizar la distribución de los valores además de usar estadísticas agregadas.

El gráfico siguiente muestra la distribución general de los retrasos de salida. La distribución es tan asimétrica que tenemos que acercarnos para ver la mayor parte de los datos. Esto sugiere que la media probablemente no sea un buen resumen y que quizá prefiramos la mediana.

flights["dep_delay"].plot.hist(
    bins=50, title="         Distribución: duración del retraso"
);

flights.query("dep_delay <= 120")["dep_delay"].plot.hist(
    bins=50, title="         Distribución: duración del retraso"
);

Dos histogramas de "dep_delay". En el primero, es muy difícil ver algún patrón, salvo que hay un pico muy grande alrededor de cero, las barras disminuyen rápidamente de altura y, en la mayor parte del gráfico, no se ve ninguna barra porque son demasiado bajas. En el segundo, donde hemos descartado los retrasos de más de dos horas, vemos que el pico se produce ligeramente por debajo de cero (es decir, la mayoría de los vuelos salen un par de minutos antes), pero aun así hay un descenso muy pronunciado después.

No tengas miedo de explorar tus propios resúmenes personalizados, adaptados específicamente a los datos con los que trabajas. En este caso, eso podría significar resumir por separado los vuelos que salieron antes y los que salieron tarde o, dado que los valores son tan asimétricos, podrías probar una transformación logarítmica. Por último, no olvides que siempre es buena idea incluir el número de observaciones de cada grupo al crear resúmenes.

Ejercicios

  1. Piensa en al menos 5 formas diferentes de evaluar las características típicas de retraso de un grupo de vuelos. Considera los siguientes escenarios:

    • Un vuelo llega 15 minutos antes el 50% de las veces y 15 minutos tarde el 50% de las veces.
    • Un vuelo siempre llega 10 minutos tarde.
    • Un vuelo llega 30 minutos antes el 50% de las veces y 30 minutos tarde el 50% de las veces.
    • El 99% de las veces un vuelo llega a tiempo. El 1% de las veces llega 2 horas tarde.

    ¿Qué crees que es más importante: el retraso de llegada o el retraso de salida?

  2. ¿Qué destinos muestran la mayor variación en la velocidad de vuelo?

  3. Crea un gráfico para explorar más a fondo las aventuras de EGE. ¿Encuentras alguna evidencia de que el aeropuerto cambió de ubicación?