Medidas de localización en Big Mart Sales

Desde la perspectiva del análisis exploratorio de datos, las medidas de localización responden a una pregunta fundamental: ¿en qué región de valores tiende a concentrarse el fenómeno observado?. En términos prácticos, esto permite identificar valores típicos, comparar distribuciones y comenzar a interpretar el comportamiento general del sistema antes de pasar a medidas de dispersión, forma o segmentación.

El dataset incluye variables numéricas como Item_Weight, Item_Visibility, Item_MRP y Item_Outlet_Sales, junto con variables categóricas asociadas a tipo de producto, establecimiento y ubicación del outlet. Para este primer analisis nos enfocaremos en el comportamiento univariado de las variables numéricas, usando medidas como la media, la mediana, la moda y algunos cuantiles.

Preparación del entorno

Trabajaremos con el siguiente dataset BigMart, para esta sección usaremos Python y las bibliotecas pandas, numpy, plotly y matplotlib.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["figure.figsize"] = (8, 4.5)
plt.rcParams["axes.grid"] = True

DATA_PATH = "../../data/bigmart_sales.csv"

df = pd.read_csv(DATA_PATH)
df.head()
Item_Identifier Item_Weight Item_Fat_Content Item_Visibility Item_Type Item_MRP Outlet_Identifier Outlet_Establishment_Year Outlet_Size Outlet_Location_Type Outlet_Type Item_Outlet_Sales
0 FDA15 9.30 Low Fat 0.016047 Dairy 249.8092 OUT049 1999 Medium Tier 1 Supermarket Type1 3735.1380
1 DRC01 5.92 Regular 0.019278 Soft Drinks 48.2692 OUT018 2009 Medium Tier 3 Supermarket Type2 443.4228
2 FDN15 17.50 Low Fat 0.016760 Meat 141.6180 OUT049 1999 Medium Tier 1 Supermarket Type1 2097.2700
3 FDX07 19.20 Regular 0.000000 Fruits and Vegetables 182.0950 OUT010 1998 NaN Tier 3 Grocery Store 732.3800
4 NCD19 8.93 Low Fat 0.000000 Household 53.8614 OUT013 1987 High Tier 3 Supermarket Type1 994.7052

Inspección inicial

Antes de calcular medidas de localización conviene identificar el tipo de variables disponibles y confirmar qué columnas numéricas serán analizadas.

df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 8523 entries, 0 to 8522
Data columns (total 12 columns):
 #   Column                     Non-Null Count  Dtype  
---  ------                     --------------  -----  
 0   Item_Identifier            8523 non-null   object 
 1   Item_Weight                7060 non-null   float64
 2   Item_Fat_Content           8523 non-null   object 
 3   Item_Visibility            8523 non-null   float64
 4   Item_Type                  8523 non-null   object 
 5   Item_MRP                   8523 non-null   float64
 6   Outlet_Identifier          8523 non-null   object 
 7   Outlet_Establishment_Year  8523 non-null   int64  
 8   Outlet_Size                6113 non-null   object 
 9   Outlet_Location_Type       8523 non-null   object 
 10  Outlet_Type                8523 non-null   object 
 11  Item_Outlet_Sales          8523 non-null   float64
dtypes: float64(4), int64(1), object(7)
memory usage: 799.2+ KB
num_cols = df.select_dtypes(include=[np.number]).columns.tolist()
cat_cols = df.select_dtypes(exclude=[np.number]).columns.tolist()

num_cols, cat_cols[:10]
(['Item_Weight',
  'Item_Visibility',
  'Item_MRP',
  'Outlet_Establishment_Year',
  'Item_Outlet_Sales'],
 ['Item_Identifier',
  'Item_Fat_Content',
  'Item_Type',
  'Outlet_Identifier',
  'Outlet_Size',
  'Outlet_Location_Type',
  'Outlet_Type'])

En este punto es útil destacar que no todas las variables numéricas tienen el mismo significado. Por ejemplo, Item_MRP representa el precio máximo sugerido del producto, mientras que Item_Outlet_Sales corresponde a las ventas del producto en un outlet particular. La interpretación del “centro” de cada variable, por tanto, depende del contexto económico de la medición.

Selección de variables para el análisis de localización

Para esta etapa trabajaremos con un subconjunto de variables numéricas especialmente interpretables:

  • Item_Weight
  • Item_Visibility
  • Item_MRP
  • Item_Outlet_Sales

Crearemos un nuevo dataframe con estas columnas para facilitar el análisis, identificando además el número de datos faltantes en las mismas.

vars_localizacion = [
    "Item_Weight",
    "Item_Visibility",
    "Item_MRP",
    "Item_Outlet_Sales",
]

missing_report = df[vars_localizacion].isna().sum().to_frame("missing")
missing_report["missing_pct"] = 100 * missing_report["missing"] / len(df)
missing_report
missing missing_pct
Item_Weight 1463 17.165317
Item_Visibility 0 0.000000
Item_MRP 0 0.000000
Item_Outlet_Sales 0 0.000000

Datos faltantes y su impacto en el análisis de localización

La presencia de valores faltantes en la variable Item_Weight no impide comenzar el análisis descriptivo de localización, sin embargo, eliminarlos sin analizar su origen puede introducir sesgos en los resultados. Por ello, antes de calcular medidas descriptivas es necesario examinar la magnitud y el posible patrón de los datos ausentes. Extrayendo un dataframe con los casos faltantes en Item_Weight tenemos:

missing_weight = df[df["Item_Weight"].isna()]
missing_weight.head()
Item_Identifier Item_Weight Item_Fat_Content Item_Visibility Item_Type Item_MRP Outlet_Identifier Outlet_Establishment_Year Outlet_Size Outlet_Location_Type Outlet_Type Item_Outlet_Sales
7 FDP10 NaN Low Fat 0.127470 Snack Foods 107.7622 OUT027 1985 Medium Tier 3 Supermarket Type3 4022.7636
18 DRI11 NaN Low Fat 0.034238 Hard Drinks 113.2834 OUT027 1985 Medium Tier 3 Supermarket Type3 2303.6680
21 FDW12 NaN Regular 0.035400 Baking Goods 144.5444 OUT027 1985 Medium Tier 3 Supermarket Type3 4064.0432
23 FDC37 NaN Low Fat 0.057557 Baking Goods 107.6938 OUT019 1985 Small Tier 1 Grocery Store 214.3876
29 FDC14 NaN Regular 0.072222 Canned 43.6454 OUT019 1985 Small Tier 1 Grocery Store 125.8362

Si bien no es el objetivo de esta sección resolver el problema de los datos faltantes, es importante tener una noción clara de su impacto. Para esto existen tres preguntas clave que podemos hacernos en estos casos:

  1. ¿Qué porcentaje de datos faltantes hay en la variable?

Este paso permite dimensionar la magnitud del problema. Calcular la proporción de valores ausentes respecto al total de observaciones ayuda a evaluar si los faltantes representan una fracción pequeña del dataset o si son lo suficientemente numerosos como para afectar la representatividad del análisis.

print(f"Porcentaje de datos faltantes en Item_Weight: {missing_report.loc['Item_Weight', 'missing_pct']:.2f}%")
Porcentaje de datos faltantes en Item_Weight: 17.17%

En nuestro caso la variable presenta aproximadamente un 17 % de valores faltantes, lo que implica que cerca de una sexta parte de las observaciones no contiene información para este atributo. Esta proporción no es despreciable, por lo que antes de excluir estos registros conviene examinar si los datos faltantes siguen algún patrón sistemático y evaluar el posible impacto de su eliminación en el análisis exploratorio.

  1. ¿Existen patrones de ausencia relacionados con otras variables?

Aquí se busca identificar si los datos faltantes aparecen de manera aleatoria o si se concentran en ciertos grupos (por ejemplo, en determinadas categorías de producto, tipos de outlet o rangos de precio). Detectar patrones de ausencia es importante porque podría indicar un posible sesgo en los datos.

Analicemos el promedio de valores faltantes en Item_Weight por tipo de producto y tipo de outlet:

df['Item_Weight'].isna().groupby(df['Item_Type']).mean()
Item_Type
Baking Goods             0.172840
Breads                   0.187251
Breakfast                0.190909
Canned                   0.169492
Dairy                    0.170088
Frozen Foods             0.161215
Fruits and Vegetables    0.172890
Hard Drinks              0.144860
Health and Hygiene       0.173077
Household                0.165934
Meat                     0.207059
Others                   0.189349
Seafood                  0.203125
Snack Foods              0.176667
Soft Drinks              0.159551
Starchy Foods            0.121622
Name: Item_Weight, dtype: float64
df['Item_Weight'].isna().groupby(df['Outlet_Type']).mean()
Outlet_Type
Grocery Store        0.487535
Supermarket Type1    0.000000
Supermarket Type2    0.000000
Supermarket Type3    1.000000
Name: Item_Weight, dtype: float64

Podemos observar como la falta de datos en Item_Weight se concentra principalmete en 2 tipos de outlets : Grocery Store y Supermarket Type1. Esto sugiere que la ausencia de información no es aleatoria en estos casos, mientras que tenemos una distribución más equilibrada en Item_Type.

Este patrón de ausencia podría estar relacionado con la naturaleza de los productos comercializados en esos outlets o con prácticas de registro de datos específicas, lo que implica que eliminar estos casos sin un análisis previo podría sesgar el análisis descriptivo.

import pandas as pd
import plotly.express as px

# Proporción de missing de Item_Weight por Outlet_Type
missing_outlet = (
    df['Item_Weight']
    .isna()
    .groupby(df['Outlet_Type'])
    .mean()
    .mul(100)
    .reset_index(name='pct_missing')
    .sort_values('pct_missing', ascending=False)
)

fig = px.bar(
    missing_outlet,
    x='Outlet_Type',
    y='pct_missing',
    color='pct_missing',
    color_continuous_scale='Reds',
    text='pct_missing',
    labels={
        'Outlet_Type': 'Tipo de outlet',
        'pct_missing': '% de faltantes en Item_Weight'
    },
    title='Porcentaje de valores faltantes de Item_Weight por Outlet_Type'
)

fig.update_traces(
    texttemplate='%{text:.1f}%',
    textposition='outside'
)

fig.update_layout(
    yaxis_title='% de faltantes',
    xaxis_title='Tipo de outlet',
    coloraxis_colorbar_title='% faltante'
)

fig.show()
  1. ¿Cuál es el impacto potencial de eliminar casos con datos faltantes en el análisis?

En este punto, la pregunta clave ya no es solo si existen datos faltantes, sino si su eliminación cambia nuestra lectura general de los datos. Por ello, una forma natural de continuar el EDA es comparar medidas de localización antes y después de excluir los casos incompletos. Si estas medidas permanecen estables, la omisión de dichos registros tendría un impacto limitado; si cambian de manera apreciable, entonces la ausencia de datos sí afecta la interpretación.

Con lo cual procederemos en las siguientes secciones a evaluar estas medidas de localizacion asociadas.

Medidas de localización básicas

Como vimos en el capitulo pasado las medidas más usadas en esta etapa son:

  • Media: \[ \bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i \]
  • Mediana: valor que deja 50% de observaciones a cada lado.
  • Moda: valor más frecuente. En variables continuas muchas veces es más útil hablar de la región modal observada en un histograma.
  • Cuantiles: percentiles como \(Q_{0.25}\), \(Q_{0.50}\) y \(Q_{0.75}\).

Estas se pueden conocer de manera rápida en pandas mediante los métodos mean(), median(), mode() y quantile(). Para facilitar la lectura comparativa, conviene construir una tabla resumen con estas medidas para cada variable numérica clave.

def mode_series(s):
    m = s.mode(dropna=True)
    if len(m) == 0:
        return np.nan
    if len(m) == 1:
        return m.iloc[0]
    return m.iloc[:5].tolist()

summary_loc = pd.DataFrame({
    "media": df[vars_localizacion].mean(numeric_only=True),
    "mediana": df[vars_localizacion].median(numeric_only=True),
    "moda": [mode_series(df[c]) for c in vars_localizacion],
    "q25": df[vars_localizacion].quantile(0.25),
    "q75": df[vars_localizacion].quantile(0.75),
    "min": df[vars_localizacion].min(numeric_only=True),
    "max": df[vars_localizacion].max(numeric_only=True),
})
summary_loc
media mediana moda q25 q75 min max
Item_Weight 12.857645 12.600000 12.1500 8.773750 16.850000 4.555 21.350000
Item_Visibility 0.066132 0.053931 0.0000 0.026989 0.094585 0.000 0.328391
Item_MRP 140.992782 143.012800 172.0422 93.826500 185.643700 31.290 266.888400
Item_Outlet_Sales 2181.288914 1794.331000 958.7520 834.247400 3101.296400 33.290 13086.964800

Esta tabla permite una primera lectura comparativa. La relación entre media y mediana es particularmente informativa. Si ambas están muy próximas, la distribución podría ser relativamente simétrica o al menos no estar muy afectada por colas extremas. Si difieren de forma notable, conviene anticipar que más adelante aparecerán señales de asimetría.

Interpretación estadística de la media y la mediana

En datos de ventas es común encontrar distribuciones con sesgo a la derecha. Esto significa que la mayor parte de las observaciones se agrupan en niveles bajos o medios, pero existe una cola de valores altos que empuja la media hacia arriba. En ese caso, la media deja de representar un valor “típico” tan bien como la mediana.

Podemos ilustrarlo directamente con histogramas sobre los que se tracen ambas medidas.

from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=vars_localizacion
)

for i, col in enumerate(vars_localizacion):
    s = df[col].dropna()
    row = i // 2 + 1
    col_pos = i % 2 + 1

    media = s.mean()
    mediana = s.median()

    # Histograma
    fig.add_trace(
        go.Histogram(
            x=s,
            nbinsx=30,
            name=col,
            showlegend=False
        ),
        row=row, col=col_pos
    )

    # Línea de la media
    fig.add_vline(
        x=media,
        line_dash="dash",
        line_width=2,
        annotation_text=f"Media = {media:.2f}",
        annotation_position="top right",
        row=row, col=col_pos
    )

    # Línea de la mediana
    fig.add_vline(
        x=mediana,
        line_dash="dot",
        line_width=2,
        annotation_text=f"Mediana = {mediana:.2f}",
        annotation_position="top left",
        row=row, col=col_pos
    )

    fig.update_xaxes(title_text=col, row=row, col=col_pos)
    fig.update_yaxes(title_text="Frecuencia", row=row, col=col_pos)

fig.update_layout(
    # title="Distribuciones con media y mediana",
    height=700,
    width=1000,
    bargap=0.05
)

fig.show()

La superposición de estas líneas verticales ofrece una interpretación inmediata. En particular, si en Item_Outlet_Sales la media aparece sensiblemente a la derecha de la mediana, puede concluirse que las ventas no se distribuyen de forma simétrica y que existen casos de ventas altas elevan el promedio. En cambio, si en Item_MRP ambas medidas son relativamente cercanas, la estructura de precios podría ser más estable o menos sesgada.

Cuantiles y lectura ampliada del centro

Cuando hablamos de localización, no siempre basta con resumir una variable mediante un único valor, como la media o la mediana. En muchos casos, resulta más útil describir la zona central donde se concentra la mayor parte de las observaciones. Para ello, los cuantiles son especialmente importantes.

En particular, los percentiles 25, 50 y 75 permiten identificar el tramo donde se encuentra el 50 % central de los datos. Esta información ayuda a responder preguntas más ricas que un simple “¿cuál es el promedio?”, por ejemplo:

  • ¿entre qué valores se concentra la mitad central de los casos?
  • ¿qué tan alejados están los valores altos respecto del centro?
  • ¿la mediana representa bien a la distribución o existen asimetrías importantes?
quantiles = df[vars_localizacion].quantile([0.10, 0.25, 0.50, 0.75, 0.90])
quantiles
Item_Weight Item_Visibility Item_MRP Item_Outlet_Sales
0.10 6.69500 0.012042 52.79560 343.5528
0.25 8.77375 0.026989 93.82650 834.2474
0.50 12.60000 0.053931 143.01280 1794.3310
0.75 16.85000 0.094585 185.64370 3101.2964
0.90 19.35000 0.139514 231.20048 4570.0512

La tabla de cuantiles permite ampliar la lectura del centro. Mientras la media resume la localización en un solo número, los cuantiles muestran cómo se distribuyen los datos alrededor de esa zona central. Así, el percentil 25 marca el punto por debajo del cual se encuentra el 25 % de las observaciones, la mediana divide la distribución en dos partes iguales, y el percentil 75 indica el límite superior del 50 % central.

Para complementar esta lectura, es útil recurrir a los boxplots, ya que representan visualmente la mediana, los cuartiles, la dispersión central y la posible presencia de valores atípicos.

from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=vars_localizacion
)

for i, var in enumerate(vars_localizacion):
    row = i // 2 + 1
    col = i % 2 + 1
    
    fig.add_trace(
        go.Box(
            y=df[var].dropna(),
            name=var,
            boxpoints="outliers",
            showlegend=False
        ),
        row=row, col=col
    )

fig.update_layout(
    title="Boxplots de variables seleccionadas",
    height=700,
    width=900
)

fig.show()

A partir de estos gráficos, la interpretación ya no se limita a un valor “promedio”, sino que puede centrarse en la estructura de la distribución. En particular, los boxplots permiten observar:

  • dónde se ubica la mediana;

  • qué tan amplio es el rango intercuartílico;

  • si la distribución parece simétrica o sesgada;

  • y si existen valores atípicos que puedan influir en la media.

De esta forma, los cuantiles y los boxplots enriquecen el análisis de localización, porque muestran no solo un centro numérico, sino también la región en la que efectivamente se concentra la mayor parte de los datos.

Impacto de eliminar faltantes en Item_Weight

Para evaluar si eliminar las observaciones con valores faltantes en Item_Weight introduce sesgos en el análisis, compararemos el comportamiento del dataset antes y después de excluir dichos casos. La idea es verificar si esta reducción de la muestra modifica de forma importante la localización de otras variables relevantes y la composición general de los datos.

Primero construimos dos subconjuntos:

  • el dataset original;
  • el dataset filtrado, conservando solo las observaciones con Item_Weight disponible.
df_complete_weight = df[df["Item_Weight"].notna()].copy()

n_total = len(df)
n_complete = len(df_complete_weight)
n_removed = n_total - n_complete

print(f"Observaciones totales: {n_total}")
print(f"Observaciones con Item_Weight disponible: {n_complete}")
print(f"Observaciones eliminadas: {n_removed}")
print(f"Porcentaje eliminado: {100 * n_removed / n_total:.2f}%")
Observaciones totales: 8523
Observaciones con Item_Weight disponible: 7060
Observaciones eliminadas: 1463
Porcentaje eliminado: 17.17%

Una primera forma de evaluar el posible sesgo consiste en comparar medidas de localización de variables relevantes antes y después del filtrado.

vars_compare = ["Item_MRP", "Item_Outlet_Sales", "Item_Visibility"]

summary_compare = pd.DataFrame({
    "media_original": df[vars_compare].mean(),
    "media_filtrado": df_complete_weight[vars_compare].mean(),
    "mediana_original": df[vars_compare].median(),
    "mediana_filtrado": df_complete_weight[vars_compare].median()
})

summary_compare["dif_media_abs"] = summary_compare["media_filtrado"] - summary_compare["media_original"]
summary_compare["dif_media_pct"] = 100 * summary_compare["dif_media_abs"] / summary_compare["media_original"]

summary_compare["dif_mediana_abs"] = summary_compare["mediana_filtrado"] - summary_compare["mediana_original"]
summary_compare["dif_mediana_pct"] = 100 * summary_compare["dif_mediana_abs"] / summary_compare["mediana_original"]

summary_compare.round(3)
media_original media_filtrado mediana_original mediana_filtrado dif_media_abs dif_media_pct dif_mediana_abs dif_mediana_pct
Item_MRP 140.993 141.241 143.013 142.730 0.248 0.176 -0.283 -0.198
Item_Outlet_Sales 2181.289 2118.627 1794.331 1789.670 -62.662 -2.873 -4.661 -0.260
Item_Visibility 0.066 0.064 0.054 0.052 -0.002 -3.279 -0.001 -2.667

Además de la comparación numérica, es útil observar gráficamente si las distribuciones cambian al eliminar los registros con Item_Weight faltante.

from plotly.subplots import make_subplots
import plotly.graph_objects as go

vars_compare = ["Item_MRP", "Item_Outlet_Sales", "Item_Visibility"]

fig = make_subplots(
    rows=1, cols=3,
    subplot_titles=vars_compare
)

for i, var in enumerate(vars_compare, start=1):
    fig.add_trace(
        go.Histogram(
            x=df[var].dropna(),
            name="Original",
            opacity=0.55,
            nbinsx=30,
            histnorm="probability density",
            showlegend=(i == 1)
        ),
        row=1, col=i
    )
    
    fig.add_trace(
        go.Histogram(
            x=df_complete_weight[var].dropna(),
            name="Sin faltantes en Item_Weight",
            opacity=0.55,
            nbinsx=30,
            histnorm="probability density",
            showlegend=(i == 1)
        ),
        row=1, col=i
    )

fig.update_layout(
    title="Distribuciones antes y después de eliminar faltantes en Item_Weight",
    barmode="overlay",
    height=450,
    width=1100
)

fig.show()

Para reforzar la lectura de localización, también podemos comparar boxplots en ambos escenarios.

fig = make_subplots(
    rows=1, cols=3,
    subplot_titles=vars_compare
)

for i, var in enumerate(vars_compare, start=1):
    fig.add_trace(
        go.Box(
            y=df[var].dropna(),
            name="Original",
            boxpoints=False,
            showlegend=(i == 1)
        ),
        row=1, col=i
    )
    
    fig.add_trace(
        go.Box(
            y=df_complete_weight[var].dropna(),
            name="Sin faltantes en Item_Weight",
            boxpoints=False,
            showlegend=(i == 1)
        ),
        row=1, col=i
    )

fig.update_layout(
    title="Comparación de boxplots antes y después del filtrado",
    height=450,
    width=1100
)

fig.show()

Finalmente, también conviene revisar si la eliminación afecta la composición de algunos grupos del negocio.

comp_outlet = pd.concat([
    df["Outlet_Type"].value_counts(normalize=True).rename("original"),
    df_complete_weight["Outlet_Type"].value_counts(normalize=True).rename("filtrado")
], axis=1)

comp_outlet["dif_pct_points"] = 100 * (comp_outlet["filtrado"] - comp_outlet["original"])
comp_outlet.round(4)
original filtrado dif_pct_points
Outlet_Type
Supermarket Type1 0.6543 0.7899 13.5596
Grocery Store 0.1271 0.0786 -4.8456
Supermarket Type3 0.1097 NaN NaN
Supermarket Type2 0.1089 0.1314 2.2563

Los resultados muestran que la eliminación de observaciones con Item_Weight faltante produce cambios pequeños en las medidas de localización de las variables analizadas. Si bien en algunos casos las diferencias porcentuales superan ligeramente el 2 %, especialmente en Item_Outlet_Sales e Item_Visibility, la magnitud de estas variaciones no parece suficiente para modificar de manera sustantiva la interpretación general del centro de las distribuciones. Por ello, dentro del contexto de este EDA, trabajar con los casos completos puede considerarse una decisión razonable y con bajo riesgo de sesgo en términos de localización.

Ejercicio sugerido

Construya una versión inicial de dashboard en Quarto que incluya:

  • una tabla resumen con media, mediana y cuantiles de las variables numéricas seleccionadas;
  • un histograma con líneas para media y mediana de Item_Outlet_Sales;
  • un gráfico de barras con la mediana de ventas por Outlet_Type;
  • un breve comentario interpretativo donde se compare el uso de la media frente a la mediana.