¿Por qué bajaron los ingresos?
En resumen: Un proyecto de análisis de datos de la pregunta al informe: limpiar, gráficos, una comprobación del ruido, desglosar la caída de ingresos por número de pedidos, categoría, día y canal, y un modelo de la pérdida con un intervalo.
Problema
«¿Por qué septiembre es tan flojo?»
El gerente ha visto el informe: los ingresos de septiembre de 2026 son 7128,80 € y las de agosto 8812,70 €. Eso es un 19,1 % menos. Tiene tres ideas sobre la mesa: parar la publicidad porque «no funciona», cambiar al proveedor de lácteos o hacer descuentos.
Cada idea responde a una causa distinta. Si la caída es ruido, no haces nada. Si viene de un producto, miras al proveedor. Si la gente compra menos, piensas en los precios. Así que la pregunta del análisis no es «qué tan grande es la caída», sino «dónde está la caída, para saber qué arreglar».
En este proyecto recorres todo el camino del curso: los datos, la limpieza, el gráfico, la comprobación del ruido, el desglose, el modelo y el informe. Los números salen del año de pedidos de la tienda, y la respuesta no está donde la esperan las ideas del gerente.
La idea
Los datos y la limpieza
Dos tablas: year_orders.csv (pedido, cliente, hora, canal, estado) y year_items.csv (las líneas de los pedidos). El valor de un pedido es la suma de sus líneas. Los pedidos cancelados no son ingresos:
Hay 11 116 pedidos, y sin los 657 cancelados quedan 10 459. Antes de calcular nada, comprueba la limpieza: cada pedido tiene un valor (faltan 0), el menor es de 0,80 €, y los días son 365, del 1 de noviembre de 2025 al 31 de octubre de 2026. Los ingresos del año son 111 186,20 €, el mismo número que en el módulo «Preparar los datos».
new, paid y shipped son pedidos de la última semana que todavía van de camino. Los cuentas: están pagados o lo estarán. La columna channel dice de dónde vino el pedido: el sitio web (web), la app (app) o el teléfono (phone).
Escribe tú la limpieza. orders['ordered_at'] debe ser una fecha, items['revenue'] los ingresos de cada línea, orders['value'] el valor de cada pedido y sold los pedidos sin los cancelados.
Solución
import pandas as pd
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
sold = orders[orders['status'] != 'cancelled']
print(len(sold), round(sold['value'].sum(), 2))sold conserva 10 459 pedidos con unos ingresos de 111 186,20 €. Si olvidas el filtro, los 657 cancelados quedan dentro y cada mes parece más fuerte de lo que es. Si sumas solo unit_price, pierdes las cantidades: dos panes cuentan como uno.
La idea
Primero el gráfico
Antes de buscar una causa, mira la caída en el contexto del año:
Septiembre es 7128,80 € frente a 8812,70 € en agosto: −19,1 %. Octubre es 9920,40 €, así que la caída no continúa. El gráfico muestra además otra cosa: diciembre pasa de 15 000 €, y enero y febrero también son bajos. Septiembre es una caída en medio de meses normales, no parte de una temporada.
Septiembre tiene 30 días y agosto 31. ¿Podría eso explicar una parte de la caída? Guarda en per_day los ingresos por día de cada mes, y en change el cambio de los ingresos por día de agosto a septiembre, en porcentaje.
Solución
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Los pedidos cancelados no cuentan como ingresos.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
daily = sold.groupby('day')['value'].sum()
monthly = sold.groupby('month')['value'].sum()
per_day = monthly / sold.groupby('month')['day'].nunique()
change = (per_day['2026-09'] / per_day['2026-08'] - 1) * 100
print(per_day.round(2))
print(round(change, 1))Por día, agosto aporta 284,28 € y septiembre 237,63 €: −16,4 %. Un día menos explica unos 3 puntos del 19,1 %, y el resto es una caída real. Dividir cada mes entre 30 es un error: febrero tiene 28 días y parece más flojo de lo que es.
La idea
¿Es ruido?
Los meses siempre se diferencian un poco. Antes de buscar una causa, comprueba si la caída es mayor que el ruido habitual. Comparas los días: 31 días de agosto contra 30 de septiembre, con la prueba de permutación de la lección sobre hipótesis. Abajo está el cambio de los ingresos por día de un mes al siguiente:
Un día de septiembre aporta 46,65 € menos de media. De 10 000 mezclas de los días, solo 74 dan una diferencia al menos así de grande: p = 0,0074. Los meses normales de marzo a agosto se mueven entre −6,5 % y +5,8 %, y septiembre está en −16,4 %. Febrero (+16,8 %) es la vuelta después de las fiestas, y octubre (+34,7 %) es la vuelta después de septiembre.
La caída no es ruido. Tiene sentido buscar una causa.
La idea
Desglosar la caída
Los ingresos son el número de pedidos por el valor de un pedido. El primer desglose es cuál de los dos baja:
Los pedidos bajan de 854 a 701 (−17,9 %), y el pedido medio es casi igual: 10,32 € y 10,17 €. El carrito no es más pequeño; hay menos pedidos. Los descuentos no son la respuesta.
¿Podría ser un producto, por ejemplo los lácteos del proveedor nuevo? Los ingresos por categoría:
Todas las categorías bajan, desde −9,0 % (frutas y verduras) hasta −34,3 % (bebidas). La tarjeta de regalo está en +14,3 %, pero son 160 € frente a 140 € en agosto, unas pocas tarjetas. Si un producto tuviera la culpa, las demás categorías estarían normales. La caída está en todas partes: no es el producto.
¿La caída está en un día de la semana, por ejemplo porque la tienda cerró los jueves? Guarda en by_wd el ingreso medio por día de cada día de la semana (filas 0–6) para agosto y septiembre (columnas), y una columna change_% con el cambio en porcentaje.
Solución
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Los pedidos cancelados no cuentan como ingresos.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
daily = sold.groupby('day')['value'].sum()
monthly = sold.groupby('month')['value'].sum()
two = daily.loc['2026-08':'2026-09']
by_wd = two.groupby([two.index.dayofweek, two.index.strftime('%Y-%m')]).mean().unstack()
by_wd['change_%'] = (by_wd['2026-09'] / by_wd['2026-08'] - 1) * 100
print(by_wd.round(1))El ingreso medio baja los siete días: desde −4,0 % el lunes hasta −41,0 % el jueves. Ningún día «ha desaparecido»: la caída es general. Con sum en lugar de mean, el miércoles sale a +18,0 %, pero solo porque septiembre tiene 5 miércoles y agosto 4. Cuando los días no aparecen en el mismo número, comparas la media.
La idea
El canal
Queda una columna que todavía no has mirado: channel. Los ingresos por mes y canal:
Ahí está. El sitio web (web) es 4517,00 € en agosto y 4660,80 € en septiembre. El teléfono (phone) es 999,70 € y 1028,70 €. Están normales. La app (app) baja de 3296,00 € a 1439,30 € y vuelve a 3755,40 € en octubre.
La caída de la app es de 1856,70 €, más que toda la caída de la tienda (1683,90 €): los otros dos canales incluso crecen un poco. Por eso bajan todas las categorías y todos los días a la vez: la app vende de todo, todos los días.
¿Qué parte de los ingresos viene de la app en un mes normal? Guarda en share la parte de app en los ingresos de cada mes, en porcentaje.
Solución
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Los pedidos cancelados no cuentan como ingresos.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
table = sold.pivot_table(index='month', columns='channel', values='value', aggfunc='sum')
share = table['app'] / table.sum(axis=1) * 100
print(share.round(1))En los meses normales, la app aporta entre el 34,0 % y el 41,8 % de los ingresos. En septiembre aporta el 20,2 %, cerca de la mitad, y en octubre otra vez el 37,9 %. La proporción es mejor número que la suma: la temporada y el número de días no la mueven, porque mueven todos los canales a la vez.
La idea
¿Cuándo exactamente?
«Septiembre» es aproximado. El equipo de la app va a pedir una fecha. Los pedidos de la app día a día, con la media móvil de 7 días, y los días alrededor de los dos extremos:
La línea baja a principios de septiembre y vuelve a principios de octubre. La tabla muestra los extremos. El 2 de septiembre la app tiene 9 pedidos, el 3 de septiembre solo 3, y el 5 de septiembre cero, mientras que el sitio web tiene 25 y 15. Al final: 6 pedidos el 1 de octubre, 8 el 2 de octubre y 12 el 3 de octubre.
La media móvil difumina los extremos unos días, así que buscas las fechas en los días sueltos. El comienzo está claro: 3 de septiembre. El final es más borroso: el 2 de octubre ya está a medio camino de vuelta, y desde el 3 de octubre la app es como antes. La caída va del 3 de septiembre al 2 de octubre, con un día de margen.
Encuentra las fechas con una regla, no a ojo. start es el primer día desde el 1 de agosto de 2026 en que la app tiene menos del 20 % de los pedidos del día. back es el primer día después de start en que los pedidos de la app son al menos la mediana de los días normales (hasta el 31 de agosto).
Solución
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Los pedidos cancelados no cuentan como ingresos.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
share = counts['app'] / counts.sum(axis=1)
normal = counts.loc[:'2026-08-31', 'app']
start = share.loc['2026-08-01':][share.loc['2026-08-01':] < 0.2].index[0]
after = counts.loc[start:, 'app']
back = after[after >= normal.median()].index[0]
print(start.date(), back.date(), normal.median())start es el 3 de septiembre de 2026: ese día la app tiene el 10,3 % de los pedidos. En los 304 días anteriores a septiembre, la proporción es menor del 20 % una sola vez (19 de abril de 2026). back es el 3 de octubre de 2026 con 12 pedidos: la mediana de los días normales es 11, y del 3 de septiembre al 2 de octubre ningún día llega ni a 10. La proporción del día es mejor señal que el conteo: no baja cuando todo el día es flojo.
La idea
¿Cuánto costó la caída?
El gerente va a preguntar cuánto dinero costó. La respuesta ingenua es «agosto menos septiembre»: 1683,90 €. Pero eso supone que septiembre debía ser como agosto. Octubre es más fuerte que agosto, así que septiembre también pudo haber sido más fuerte.
Los canales que funcionan dan una comparación mejor. En tiempos normales, la app aporta una proporción estable respecto al sitio web y al teléfono. Esa proporción k es un modelo con un solo número: predice los ingresos de la app a partir de los ingresos de los otros dos canales en los mismos días. Lo aprendes con los días anteriores a la caída y lo compruebas con días que no ha visto: octubre, después de la caída.
Por cada 1 € de la web y del teléfono, la app aporta 0,641 €. Para octubre, después de la caída, el modelo dice 3688,92 € y la realidad es 3614,60 €: un error del 2,1 %. Para los días del 3 de septiembre al 2 de octubre el modelo espera 3713,41 €, y la app aportó 1292,60 €. La pérdida es de 2420,81 €, 737 € más que con la comparación ingenua.
¿Qué tan exacto es eso? La proporción k en 30 días no es siempre la misma. Con el bootstrap sacas 30 días normales, 10 000 veces, y ves cuánto se mueve la pérdida:
En 30 días normales al azar, k está entre 0,457 y 0,906. El intervalo del 95 % para la pérdida va de 1802,46 € a 3142,14 €. En el informe eso es «unos 2400 €, entre 1800 € y 3150 €». La estimación es burda, pero incluso el límite inferior está por encima de los 1683,90 € ingenuos.
Más precisamente, para curiosos
El modelo app = k × (web + phone) tiene una característica y un número, como una regresión lineal sin intercepto. Su comprobación es como en el módulo sobre modelos: lo aprendes con unos días (antes del 3 de septiembre) y lo mides con otros (después del 2 de octubre). Supone que los canales se mueven juntos, y la comprobación con octubre lo confirma. Si los clientes de la app se hubieran pasado al sitio web en septiembre, el modelo sobrestimaría la pérdida: contaría como perdidos pedidos que en realidad llegaron por la web. La web en septiembre está +3,2 % respecto a agosto, dentro del movimiento habitual, así que la mayoría de los pedidos perdidos no se fueron a otro sitio.
¿Cuántos pedidos perdió la app? Construye el mismo modelo para el número de pedidos: k es app / (web + phone) en los días anteriores al 3 de septiembre, expected es cuántos pedidos de la app habría habido del 3 de septiembre al 2 de octubre, actual cuántos hubo, y lost_pct qué parte de los esperados se perdió, en porcentaje.
Solución
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Los pedidos cancelados no cuentan como ingresos.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
counts['rest'] = counts['web'] + counts['phone']
day = counts.index
before = day < '2026-09-03'
drop = (day >= '2026-09-03') & (day <= '2026-10-02')
k = counts.loc[before, 'app'].sum() / counts.loc[before, 'rest'].sum()
expected = k * counts.loc[drop, 'rest'].sum()
actual = counts.loc[drop, 'app'].sum()
lost_pct = (expected - actual) / expected * 100
print(round(expected, 1), actual, round(lost_pct, 1))La app habría tenido unos 368,6 pedidos, y tuvo 126. Se perdió el 65,8 %, cerca de dos de cada tres. Si multiplicas k por los pedidos de la propia app, el modelo predice el canal roto a partir de sí mismo y no ve la pérdida.
La idea
El informe
Un análisis termina con una historia que alguien puede leer en un minuto. Tres frases, un gráfico, los límites.
- Qué: los ingresos de septiembre están un 19,1 % por debajo de agosto, porque del 3 de septiembre al 2 de octubre la app tuvo cerca de dos tercios menos de pedidos; el sitio web y el teléfono están normales, y desde el 3 de octubre la app también es normal.
- Cuánto: la pérdida es de unos 2400 € (entre 1800 € y 3150 €), unos 240 pedidos.
- Qué hacer: revisar qué cambió en la app alrededor del 3 de septiembre (una actualización, el carrito, el pago) y añadir una alerta para cuando baje la proporción de un canal.
Los límites. Los datos dicen cuándo y dónde: en la app, desde el 3 de septiembre. No dicen por qué. Una actualización de la app alrededor del 3 de septiembre es una hipótesis, no un hecho: el equipo de la app la comprueba, con los registros del carrito y los errores de pago de esa fecha. También podría ser otra cosa: la tienda de aplicaciones, un certificado vencido, un cambio en el proveedor de pagos. El gráfico no puede distinguir estas causas. Por eso el informe dice «revisar», no «fue esto».
La publicidad, el proveedor y los precios no son la causa. Ninguna de las tres ideas del gerente habría devuelto los ingresos.
Ejercicio
Una alerta para la próxima vez
La caída duró un mes porque nadie miraba el canal. Una regla de alerta debe detectar la caída rápido y quedarse callada en tiempos normales. Una alerta que salta todas las semanas, nadie la mira.
Construye la alerta. week es el número de pedidos por canal de los últimos 7 días (rolling(7).sum()), app_share la proporción de app en ellos, y alerts los días en que app_share es menor que 0.25. ¿Cuántas alarmas hay antes del 3 de septiembre, y cuándo es la primera?
Solución
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
# Los pedidos cancelados no cuentan como ingresos.
sold = orders[orders['status'] != 'cancelled']
sold['month'] = sold['ordered_at'].dt.strftime('%Y-%m')
sold['day'] = sold['ordered_at'].dt.normalize()
counts = sold.pivot_table(index='day', columns='channel', values='id', aggfunc='count', fill_value=0)
week = counts.rolling(7).sum()
app_share = week['app'] / week[['web', 'app', 'phone']].sum(axis=1)
alerts = app_share[app_share < 0.25]
print(len(alerts.loc[:'2026-09-02']), alerts.index[0].date())
print(round(app_share.loc[:'2026-09-02'].min(), 3))Antes del 3 de septiembre no hay ni una sola alarma: la proporción de 7 días es al menos 0,308 (el 15 de junio de 2026). La primera alarma es el 6 de septiembre, tres días después del comienzo, en lugar de un mes más tarde. Con la proporción de cada día por separado habría habido 11 falsas alarmas, y con un umbral de 0,35 hasta 42 días con alarma. El umbral lo eliges con los días normales: un poco por debajo del valor más bajo que alcanzan.
La idea
Lo que sigue
El curso termina aquí, y el trabajo con datos continúa. Hacia dónde:
- Notebooks. Haz este proyecto en JupyterLab, en tu propia computadora: celda por celda, con la salida debajo de cada una. El notebook es también el informe: código, gráficos y texto en un solo lugar.
- Datos más grandes. pandas lo guarda todo en memoria y trabaja en un solo núcleo. Polars tiene las mismas ideas (grupos, uniones, fechas), pero usa todos los núcleos y lee archivos más grandes que la memoria:
pl.read_csv('year_orders.csv').group_by('channel').len()cuenta los pedidos por canal. - Dashboards. La alerta del ejercicio queda mejor en un dashboard que se actualiza solo. Streamlit y Dash hacen un dashboard web a partir de un script de Python, con gráficos y filtros.
- Gráficos. seaborn y plotly son capas sobre las ideas de matplotlib: seaborn da gráficos estadísticos en una línea, y plotly gráficos interactivos, con zoom y tooltips.
- Bases de datos. En una tienda real los pedidos están en una base de datos, no en un CSV.
pd.read_sqllee el resultado de una consulta SQL directamente en un DataFrame. SQL se aprende en el curso Bases de datos y SQL.
El camino sigue siendo el mismo: la pregunta, los datos, la limpieza, la exploración, la conclusión con sus límites y la historia.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Intervalos de confianza · Contrastar una hipótesis · Correlación y causa · Modelos: aprender de ejemplos
- Ejercicios sobre el tema: Proyecto: por qué bajaron los ingresos