Medidas de concentración en Big Mart Sales

Hasta este punto del análisis exploratorio ya hemos estudiado la localización, la variabilidad, la forma y la heterogeneidad de las variables principales del conjunto de datos Big Mart Sales. Falta ahora una dimensión especialmente útil cuando se trabaja con datos económicos, comerciales y administrativos: la concentración.

La idea central es simple. No basta con saber cuánto venden, en promedio, los productos o los outlets; también interesa conocer cómo se reparte el total de ventas entre categorías, tipos de tienda o establecimientos concretos. Dos escenarios pueden tener la misma venta total y la misma media, pero diferir radicalmente en su estructura interna: en uno las ventas pueden estar distribuidas de manera relativamente equilibrada, mientras que en otro una pequeña fracción de categorías o sucursales puede concentrar una porción muy grande del ingreso total.

Desde una perspectiva de EDA, las medidas y visualizaciones de concentración ayudan a responder preguntas como las siguientes:

En este capítulo se desarrollan medidas descriptivas y representaciones gráficas para estudiar la concentración de las ventas en Big Mart Sales, concluyendo con un mini-dashboard de concentración que después se integrará al dashboard global del libro.

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 numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go

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

Como en capítulos anteriores, conviene revisar que las variables categóricas relevantes estén limpias y listas para agruparse.

# Estandarización mínima de una variable categórica clásica en Big Mart
if "Item_Fat_Content" in df.columns:
    df["Item_Fat_Content"] = (
        df["Item_Fat_Content"]
        .replace({
            "LF": "Low Fat",
            "low fat": "Low Fat",
            "reg": "Regular"
        })
    )

# Vista rápida de columnas útiles para concentración
candidate_cols = [
    "Item_Identifier",
    "Item_Type",
    "Outlet_Identifier",
    "Outlet_Type",
    "Outlet_Location_Type",
    "Outlet_Size",
    "Item_Outlet_Sales",
]

existing = [c for c in candidate_cols if c in df.columns]
df[existing].head()
Item_Identifier Item_Type Outlet_Identifier Outlet_Type Outlet_Location_Type Outlet_Size Item_Outlet_Sales
0 FDA15 Dairy OUT049 Supermarket Type1 Tier 1 Medium 3735.1380
1 DRC01 Soft Drinks OUT018 Supermarket Type2 Tier 3 Medium 443.4228
2 FDN15 Meat OUT049 Supermarket Type1 Tier 1 Medium 2097.2700
3 FDX07 Fruits and Vegetables OUT010 Grocery Store Tier 3 NaN 732.3800
4 NCD19 Household OUT013 Supermarket Type1 Tier 3 High 994.7052

Fundamento conceptual

Participación relativa

Sea \(x_i\) el total de ventas asociado a la categoría o unidad \(i\). La participación relativa de esa unidad respecto del total se define por

\[ p_i = \frac{x_i}{\sum_{j=1}^{n} x_j}. \]

Esta cantidad permite medir el peso de cada categoría en el total agregado. En términos porcentuales:

\[ p_i^{(\%)} = 100 \cdot p_i. \]

Participación acumulada

Si ordenamos las unidades de mayor a menor según su contribución, la participación acumulada hasta la posición \(k\) está dada por

\[ P_k = \sum_{i=1}^{k} p_{(i)}, \]

donde \(p_{(i)}\) representa la participación de la \(i\)-ésima unidad en el orden descendente.

Esta medida es la base del análisis de Pareto, que permite estudiar cuántas categorías explican cierto porcentaje del total.

Curva de concentración

Otra idea útil consiste en comparar la acumulación observada con una situación ideal de reparto uniforme. Si unas pocas unidades concentran gran parte del total, la curva acumulada crecerá rápidamente al inicio y se separará de la diagonal de reparto uniforme.

Aunque en cursos introductorios puede evitarse una formalización excesiva, esta representación es la antesala conceptual de herramientas más avanzadas como la curva de Lorenz y el índice de Gini.

Concentración de ventas por categoría de producto

Comenzamos agregando la venta total por tipo de producto.

sales_by_item_type = (
    df.groupby("Item_Type", dropna=False)["Item_Outlet_Sales"]
      .sum()
      .sort_values(ascending=False)
      .reset_index()
)

sales_by_item_type["share"] = (
    sales_by_item_type["Item_Outlet_Sales"] /
    sales_by_item_type["Item_Outlet_Sales"].sum()
)

sales_by_item_type["cum_share"] = sales_by_item_type["share"].cumsum()
sales_by_item_type["share_pct"] = 100 * sales_by_item_type["share"]
sales_by_item_type["cum_share_pct"] = 100 * sales_by_item_type["cum_share"]

sales_by_item_type.head(10)
Item_Type Item_Outlet_Sales share cum_share share_pct cum_share_pct
0 Fruits and Vegetables 2.820060e+06 0.151688 0.151688 15.168849 15.168849
1 Snack Foods 2.732786e+06 0.146994 0.298683 14.699412 29.868261
2 Household 2.055494e+06 0.110563 0.409246 11.056317 40.924578
3 Frozen Foods 1.825735e+06 0.098205 0.507450 9.820464 50.745042
4 Dairy 1.522594e+06 0.081899 0.589349 8.189897 58.934939
5 Canned 1.444151e+06 0.077680 0.667029 7.767962 66.702901
6 Baking Goods 1.265525e+06 0.068071 0.735100 6.807148 73.510048
7 Health and Hygiene 1.045200e+06 0.056220 0.791321 5.622038 79.132086
8 Meat 9.175656e+05 0.049355 0.840676 4.935503 84.067590
9 Soft Drinks 8.928977e+05 0.048028 0.888704 4.802817 88.870407

Ranking de contribución

Una primera lectura descriptiva consiste en ordenar las categorías por ventas y observar su peso relativo.

fig = px.bar(
    sales_by_item_type,
    x="Item_Type",
    y="share_pct",
    title="Participación porcentual de ventas por tipo de producto",
    labels={"share_pct": "Participación (%)", "Item_Type": "Tipo de producto"}
)
fig.update_layout(xaxis_tickangle=-45)
fig.show()

Este gráfico responde a una pregunta básica: qué categorías pesan más en la estructura total de ventas. En un contexto de negocio, esto ayuda a distinguir líneas estratégicas, categorías troncales y segmentos con menor incidencia.

Gráfico de Pareto

El gráfico de Pareto combina barras de contribución individual con una curva acumulada. Es una de las formas más didácticas de introducir la idea de concentración.

pareto_df = sales_by_item_type.copy()

fig = go.Figure()
fig.add_bar(
    x=pareto_df["Item_Type"],
    y=pareto_df["share_pct"],
    name="Participación individual (%)"
)
fig.add_scatter(
    x=pareto_df["Item_Type"],
    y=pareto_df["cum_share_pct"],
    mode="lines+markers",
    name="Participación acumulada (%)",
    yaxis="y2"
)

fig.update_layout(
    title="Gráfico de Pareto de ventas por tipo de producto",
    xaxis=dict(title="Tipo de producto"),
    yaxis=dict(title="Participación individual (%)"),
    yaxis2=dict(
        title="Participación acumulada (%)",
        overlaying="y",
        side="right",
        range=[0, 105]
    ),
    legend=dict(orientation="h"),
)
fig.update_xaxes(tickangle=-45)
fig.show()

Interpretación

Cuando la curva acumulada crece muy rápido al inicio, ello indica que unas pocas categorías concentran una gran parte de las ventas. Si, en cambio, la acumulación es más gradual, la estructura es relativamente más equilibrada.

Una buena práctica pedagógica consiste en cuantificar preguntas como:

  • ¿Cuántas categorías explican el 50% de las ventas?
  • ¿Cuántas categorías explican el 80%?
  • ¿Qué fracción del total se concentra en las tres categorías principales?
def categories_to_reach_threshold(table, threshold=0.8):
    idx = np.argmax(table["cum_share"].to_numpy() >= threshold)
    return idx + 1

n50 = categories_to_reach_threshold(sales_by_item_type, 0.50)
n80 = categories_to_reach_threshold(sales_by_item_type, 0.80)

top3_share = sales_by_item_type["share"].head(3).sum() * 100

summary_concentration_item = pd.DataFrame({
    "Indicador": [
        "Categorías para alcanzar 50% de ventas",
        "Categorías para alcanzar 80% de ventas",
        "Participación acumulada de las 3 principales (%)"
    ],
    "Valor": [n50, n80, round(top3_share, 2)]
})

summary_concentration_item
Indicador Valor
0 Categorías para alcanzar 50% de ventas 4.00
1 Categorías para alcanzar 80% de ventas 9.00
2 Participación acumulada de las 3 principales (%) 40.92

Concentración de ventas por outlet

La concentración también puede analizarse desde el punto de vista de las tiendas o outlets. Esta mirada es importante porque distingue entre un portafolio equilibrado de establecimientos y una estructura dominada por unas pocas unidades.

sales_by_outlet = (
    df.groupby("Outlet_Identifier", dropna=False)["Item_Outlet_Sales"]
      .sum()
      .sort_values(ascending=False)
      .reset_index()
)

sales_by_outlet["share"] = (
    sales_by_outlet["Item_Outlet_Sales"] /
    sales_by_outlet["Item_Outlet_Sales"].sum()
)
sales_by_outlet["cum_share"] = sales_by_outlet["share"].cumsum()
sales_by_outlet["share_pct"] = 100 * sales_by_outlet["share"]
sales_by_outlet["cum_share_pct"] = 100 * sales_by_outlet["cum_share"]

sales_by_outlet
Outlet_Identifier Item_Outlet_Sales share cum_share share_pct cum_share_pct
0 OUT027 3.453926e+06 0.185784 0.185784 18.578359 18.578359
1 OUT035 2.268123e+06 0.122000 0.307784 12.200030 30.778389
2 OUT049 2.183970e+06 0.117474 0.425258 11.747378 42.525768
3 OUT017 2.167465e+06 0.116586 0.541844 11.658602 54.184370
4 OUT013 2.142664e+06 0.115252 0.657096 11.525196 65.709565
5 OUT046 2.118395e+06 0.113947 0.771042 11.394658 77.104223
6 OUT045 2.036725e+06 0.109554 0.880596 10.955364 88.059587
7 OUT018 1.851823e+06 0.099608 0.980204 9.960789 98.020377
8 OUT010 1.883402e+05 0.010131 0.990334 1.013065 99.033442
9 OUT019 1.796941e+05 0.009666 1.000000 0.966558 100.000000
fig = go.Figure()
fig.add_bar(
    x=sales_by_outlet["Outlet_Identifier"],
    y=sales_by_outlet["share_pct"],
    name="Participación individual (%)"
)
fig.add_scatter(
    x=sales_by_outlet["Outlet_Identifier"],
    y=sales_by_outlet["cum_share_pct"],
    mode="lines+markers",
    name="Participación acumulada (%)",
    yaxis="y2"
)
fig.update_layout(
    title="Concentración de ventas por outlet",
    xaxis=dict(title="Outlet"),
    yaxis=dict(title="Participación individual (%)"),
    yaxis2=dict(
        title="Participación acumulada (%)",
        overlaying="y",
        side="right",
        range=[0, 105]
    ),
    legend=dict(orientation="h")
)
fig.show()

Comentario analítico

A diferencia del análisis por tipo de producto, aquí suele haber menos unidades agregadas, de modo que la concentración puede hacerse visible con mayor claridad. Si pocas tiendas explican una fracción grande de las ventas, ello puede tener implicaciones operativas, logísticas o estratégicas.

Concentración por tipo de outlet

Además de estudiar outlets individuales, conviene analizar el reparto de ventas según tipo de tienda.

sales_by_outlet_type = (
    df.groupby("Outlet_Type", dropna=False)["Item_Outlet_Sales"]
      .sum()
      .sort_values(ascending=False)
      .reset_index()
)

sales_by_outlet_type["share"] = (
    sales_by_outlet_type["Item_Outlet_Sales"] /
    sales_by_outlet_type["Item_Outlet_Sales"].sum()
)
sales_by_outlet_type["cum_share"] = sales_by_outlet_type["share"].cumsum()
sales_by_outlet_type["share_pct"] = 100 * sales_by_outlet_type["share"]
sales_by_outlet_type["cum_share_pct"] = 100 * sales_by_outlet_type["cum_share"]

sales_by_outlet_type
Outlet_Type Item_Outlet_Sales share cum_share share_pct cum_share_pct
0 Supermarket Type1 1.291734e+07 0.694812 0.694812 69.481228 69.481228
1 Supermarket Type3 3.453926e+06 0.185784 0.880596 18.578359 88.059587
2 Supermarket Type2 1.851823e+06 0.099608 0.980204 9.960789 98.020377
3 Grocery Store 3.680343e+05 0.019796 1.000000 1.979623 100.000000
fig = px.bar(
    sales_by_outlet_type,
    x="Outlet_Type",
    y="share_pct",
    title="Participación de ventas por tipo de outlet",
    labels={"share_pct": "Participación (%)", "Outlet_Type": "Tipo de outlet"}
)
fig.update_layout(xaxis_tickangle=-20)
fig.show()

Curva de concentración simplificada

Para construir una curva de concentración simple, ordenamos las unidades por contribución creciente o decreciente y comparamos la acumulación observada contra una referencia uniforme.

Aquí haremos una versión descriptiva, suficiente para fines pedagógicos.

def concentration_curve(values):
    values = np.asarray(values, dtype=float)
    values = np.sort(values)  # ascendente
    cum_values = np.cumsum(values)
    cum_values = cum_values / cum_values[-1]
    cum_units = np.arange(1, len(values) + 1) / len(values)
    return cum_units, cum_values

x_units, y_conc = concentration_curve(sales_by_item_type["Item_Outlet_Sales"])

curve_df = pd.DataFrame({
    "Fraccion_unidades": x_units,
    "Fraccion_ventas": y_conc
})

fig = go.Figure()
fig.add_scatter(
    x=curve_df["Fraccion_unidades"],
    y=curve_df["Fraccion_ventas"],
    mode="lines",
    name="Curva de concentración"
)
fig.add_scatter(
    x=[0, 1],
    y=[0, 1],
    mode="lines",
    name="Reparto uniforme"
)
fig.update_layout(
    title="Curva de concentración de ventas por tipo de producto",
    xaxis_title="Fracción acumulada de categorías",
    yaxis_title="Fracción acumulada de ventas"
)
fig.show()

Lectura de la curva

Cuanto más separada esté la curva respecto de la diagonal de reparto uniforme, mayor será la concentración. En un reparto perfectamente equilibrado, ambas coincidirían. En cambio, una curva muy hundida al inicio y muy empinada al final refleja que pocas categorías explican gran parte del volumen total.

Indicadores resumidos de concentración

A modo de síntesis, conviene construir una tabla con indicadores descriptivos que resuman la estructura observada.

top1_item = sales_by_item_type["share"].iloc[0] * 100
top5_item = sales_by_item_type["share"].head(5).sum() * 100

top1_outlet = sales_by_outlet["share"].iloc[0] * 100
top3_outlet = sales_by_outlet["share"].head(3).sum() * 100

concentration_summary = pd.DataFrame({
    "Ámbito": [
        "Productos",
        "Productos",
        "Productos",
        "Outlets",
        "Outlets"
    ],
    "Indicador": [
        "Participación de la categoría líder (%)",
        "Participación acumulada del top 5 (%)",
        "Categorías necesarias para 80% de ventas",
        "Participación del outlet líder (%)",
        "Participación acumulada de los 3 principales outlets (%)"
    ],
    "Valor": [
        round(top1_item, 2),
        round(top5_item, 2),
        n80,
        round(top1_outlet, 2),
        round(top3_outlet, 2)
    ]
})

concentration_summary
Ámbito Indicador Valor
0 Productos Participación de la categoría líder (%) 15.17
1 Productos Participación acumulada del top 5 (%) 58.93
2 Productos Categorías necesarias para 80% de ventas 9.00
3 Outlets Participación del outlet líder (%) 18.58
4 Outlets Participación acumulada de los 3 principales o... 42.53

Mini-dashboard de concentración

El objetivo final del capítulo es reunir varias perspectivas en un panel compacto. En un book de Quarto, una estrategia práctica consiste en combinar tablas de KPIs con gráficos interactivos.

KPIs principales

total_sales = df["Item_Outlet_Sales"].sum()
n_item_types = df["Item_Type"].nunique() if "Item_Type" in df.columns else np.nan
n_outlets = df["Outlet_Identifier"].nunique() if "Outlet_Identifier" in df.columns else np.nan

dashboard_kpis = pd.DataFrame({
    "Métrica": [
        "Ventas totales",
        "Tipos de producto",
        "Outlets",
        "Top 3 categorías (%)",
        "Top 3 outlets (%)"
    ],
    "Valor": [
        round(total_sales, 2),
        n_item_types,
        n_outlets,
        round(sales_by_item_type["share"].head(3).sum() * 100, 2),
        round(sales_by_outlet["share"].head(3).sum() * 100, 2)
    ]
})

dashboard_kpis
Métrica Valor
0 Ventas totales 18591125.41
1 Tipos de producto 16.00
2 Outlets 10.00
3 Top 3 categorías (%) 40.92
4 Top 3 outlets (%) 42.53

Discusión final

La concentración es un componente esencial del EDA porque complementa lo aprendido en capítulos previos. Mientras la localización resume el centro, la variabilidad describe la dispersión, la forma estudia la geometría de la distribución y la heterogeneidad compara subgrupos, la concentración revela cómo se reparte el total entre las unidades que componen el sistema.

En Big Mart Sales, esta dimensión permite identificar si el negocio depende fuertemente de unas pocas categorías o unos pocos outlets, o si el ingreso se distribuye de manera más equilibrada. Esta información es valiosa tanto para fines descriptivos como para una primera interpretación operativa.

Ejercicios sugeridos

  1. Construye el gráfico de Pareto para Outlet_Location_Type y compara su concentración con la observada en Outlet_Type.
  2. Calcula qué porcentaje de ventas acumula el 20% de las categorías con mayores ventas.
  3. Repite el análisis utilizando el número de registros por categoría en lugar del total de ventas, y compara ambos resultados.
  4. Describe, con lenguaje de negocio, si la estructura de ventas parece diversificada o concentrada.