- Pedidos, catálogo y marketing con revisión y trazabilidad de calidad.
- KPIs de revenue, costos, marketing y resultado económico.
- Concentración de productos, países y canales.
- Consultas SQL de eventos y ratios del funnel.
- Retención por cohortes semanales de registro.
- Prueba A/B de la nueva interfaz de checkout.
- Dashboard ejecutivo y detalle de producto con drill-through.
De datos a decisiones de negocio
El proyecto final de RappiPlus integra limpieza auditable, rentabilidad, funnel, retención y experimentación. Python, SQL y Power BI conectan 24.600 pedidos completos con preguntas concretas sobre costos, concentración de ventas y experiencia de compra.
Notebook de consulta e informe PDF incorporados en el HTML. La copia del notebook excluye credenciales de conexión.
Resumen del proyecto final
El proceso recorre seis etapas analíticas y una entrega documentada. El periodo comercial mostrado corresponde a enero–junio de 2025, con tres países, siete productos y tres canales de referencia.
El análisis distingue datos incompletos de datos recuperables, mantiene archivos de auditoría y evita imputar cifras que alteren artificialmente los resultados comerciales.
Combina métricas económicas con actividad de usuarios y evidencia experimental. Las recomendaciones separan anomalías observadas de explicaciones todavía no demostradas.
| Indicador | Importe | Definición / alcance |
|---|---|---|
| Revenue | $51.836.375,14 | Suma de monto_total en 24.600 pedidos completos. |
| Costo de productos | $43.078.678,80 | Costo unitario del catálogo × cantidad vendida. |
| Profit de Power BI | $8.757.696,34 | Revenue − costo de productos; antes de marketing. |
| Marketing identificado | $2.694.664,43 | Suma del gasto en marketing_clean. |
| Resultado tras costos y marketing | $6.063.031,91 | Revenue − costo − marketing identificado. |
| Margen tras marketing | 11,70 % | Resultado tras marketing / revenue. |
| Margen mostrado en BI | 16,89 % | Profit antes de marketing / revenue. |
| Ticket promedio | $2.107,17 | Revenue por pedido completo. |
Power BI muestra $8.757.696,34 antes de marketing. Python calcula $6.063.031,91 al restar $2.694.664,43 de marketing identificado. Ambos resultados son compatibles, pero responden a definiciones distintas.
El proceso, de principio a fin
Limpieza, análisis comercial, consultas SQL, cohortes, prueba estadística y comunicación visual mantienen la secuencia del notebook.
Paso 1. Cargar, limpiar y auditar los datos
La ingesta utiliza pedidos, catálogo y gasto de marketing. Una función de calidad revisa estructura, tipos, nulos, duplicados y categorías. Las fechas se convierten a datetime y se normalizan países y categorías.
Los valores no positivos en cantidad, precio y monto se convierten a nulos; el descuento cero se conserva porque es válido. La consistencia se revisa con cantidad × precio_unitario − monto_descuento y tolerancia de un centavo.
Se eliminan 100 repeticiones de pedidos. El catálogo permite completar 50 categorías; los datos imposibles de reconstruir se separan en archivos auditables, sin inventar países ni precios.
| Fuente / tratamiento | Filas | Destino o criterio |
|---|---|---|
| Orders original | 25.100 | 12 columnas; 100 repeticiones de pedidos. |
| Orders tras deduplicar | 25.000 | Un registro por id_pedido. |
| Orders completo | 24.600 | Base de ventas para los KPIs y Power BI. |
| Orders auditable | 400 | Registros incompletos conservados para trazabilidad. |
| Marketing original | 1.620 | Gasto con canal identificado o ausente. |
| Marketing completo | 1.519 | Registros con canal conocido. |
| Marketing auditable | 101 | Canal nulo, gasto conservado para revisión. |
| Catálogo | 7 productos | Referencia de categoría, proveedor y costo unitario. |
Los 400 pedidos auditables no se borran del rastro del proyecto. Marketing con canal ausente también se conserva aparte; su gasto no forma parte del indicador de marketing_clean.
Ver código y resultados documentados
Celda 14 · código documentado
# Validar y convertir fechas al formato correcto
orders['fecha_hora_pedido'] = pd.to_datetime(orders['fecha_hora_pedido'])
marketing['fecha'] = pd.to_datetime(marketing['fecha'])Celda 22 · código documentado
# LIMPIEZA Y ESTANDARIZACIÓN DEL VARIABLES NUMERICAS EN ORDERS
# Reemplazar valores negativos por la mediana de cantidad y monto total
orders.info()
# ============================================================
# REEMPLAZAR VALORES MENORES O IGUALES A CERO POR NaN
# ============================================================
# Crear una lista con las columnas numéricas donde
# los valores deben ser mayores que cero.
#
# No se incluye monto_descuento porque un descuento de 0 es válido.
columnas_a_limpiar = [
'cantidad',
'precio_unitario',
'monto_total'
]
# Recorrer cada columna de la lista.
for columna in columnas_a_limpiar:
# Buscar los valores menores o iguales a cero.
#
# <= 0 significa:
# menor que cero O igual a cero.
#
# np.nan representa un valor faltante.
#
# loc permite modificar únicamente las filas que cumplen
# la condición indicada.
orders.loc[orders[columna] <= 0, columna] = np.nan
#Verificar cambios
explorar(orders, col_num_orders)
valores_nulos(orders, col_num_orders)<class 'pandas.core.frame.DataFrame'> RangeIndex: 25100 entries, 0 to 25099 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id_pedido 25100 non-null object 1 id_usuario 25100 non-null object 2 fecha_hora_pedido 25100 non-null datetime64[ns] 3 pais 24800 non-null object 4 dispositivo 25080 non-null object 5 fuente_referencia 25070 non-null object 6 nombre_producto 25070 non-null object 7 categoria_producto 25020 non-null object 8 cantidad 25050 non-null float64 9 precio_unitario 25050 non-null float64 10 monto_descuento 25050 non-null float64 11 monto_total 25100 non-null float64 dtypes: datetime64[ns](1), float64(4), object(7) memory usage: 2.3+ MB Estadisticas descriptivas de cantidad Mediana: 2.0 count 25046.000000 mean 7.094067 std 296.300643 min 1.000000 25% 1.000000 50% 2.000000 75% 2.000000 max 20000.000000 Name: cantidad, dtype: float64 ___________________________ Estadisticas descriptivas de precio_unitario Mediana: 258.71500000000003 count 25050.000000 mean 259.305549 std 138.726461 min 20.030000 25% 138.377500 50% 258.715000 75% 380.332500 max 499.960000 Name: precio_unitario, dtype: float64 ___________________________ Estadisticas descriptivas de monto_descuento Mediana: 0.0 count 25050.000000 mean 4.500798 std 5.223010 min 0.000000 25% 0.000000 50% 0.000000 75% 10.000000 max 15.000000 Name: monto_descuento, dtype: float64 ___________________________ Estadisticas descriptivas de monto_total Mediana: 341.80499999999995 count 2.509600e+04 mean 2.073056e+03 std 9.895783e+04 min 5.240000e+00 25% 1.805925e+02 50% 3.418050e+02 75% 5.185950e+02 max 8.840200e+06 Name: monto_total, dtype: float64 ___________________________ Cantidad de valores nulos en columna cantidad 54 Proporción de valores nulos en columna cantidad 0.002151394422310757 _____________________________________ Cantidad de valores nulos en columna precio_unitario 50 Proporción de valores nulos en columna precio_unitario 0.00199203187250996 _____________________________________ Cantidad de valores nulos en columna monto_descuento 50 Proporción de valores nulos en columna monto_descuento 0.00199203187250996 _____________________________________ Cantidad de valores nulos en columna monto_total 4 Proporción de valores nulos en columna monto_total 0.00015936254980079682 _____________________________________
Celda 26 · código documentado
# ============================================================
# VERIFICAR CONSISTENCIA ENTRE CANTIDAD, PRECIO Y MONTO TOTAL
# ============================================================
# ------------------------------------------------------------
# 1. CALCULAR EL MONTO TOTAL ESPERADO
# ------------------------------------------------------------
# La fórmula es:
#
# cantidad * precio_unitario - monto_descuento
#
# El resultado se guarda en una nueva columna llamada
# 'monto_total_calculado'.
orders['monto_total_calculado'] = (
orders['cantidad'] * orders['precio_unitario']
) - orders['monto_descuento']
# ------------------------------------------------------------
# 2. CALCULAR LA DIFERENCIA ENTRE AMBOS MONTOS
# ------------------------------------------------------------
# Restamos el monto informado en la base de datos menos
# el monto calculado con la fórmula.
#
# Si la diferencia es 0, el monto es consistente.
# Si la diferencia es distinta de 0, puede haber inconsistencia.
orders['diferencia_monto'] = (
orders['monto_total'] - orders['monto_total_calculado']
)
# ------------------------------------------------------------
# 3. IDENTIFICAR LAS FILAS INCONSISTENTES
# ------------------------------------------------------------
# Identificar las filas que tienen todos los valores necesarios
# para verificar la fórmula:
# cantidad * precio_unitario - monto_descuento.
filas_completas = orders[
[
'cantidad',
'precio_unitario',
'monto_descuento',
'monto_total'
]
].notna().all(axis=1)
# Redondear la diferencia a dos decimales.
# Con esto, 0.0100000000005 se convierte en 0.01.
diferencia_redondeada = orders['diferencia_monto'].round(2)
# Marcar como inconsistentes únicamente diferencias de 2 centavos
# o más, tanto positivas como negativas.
#
# 0.01 y -0.01 se consideran diferencias aceptables por redondeo.
filas_inconsistentes = (
filas_completas
& (
(diferencia_redondeada >= 0.02)
| (diferencia_redondeada <= -0.02)
)
)
# ------------------------------------------------------------
# 4. CREAR UNA TABLA SOLO CON LAS INCONSISTENCIAS
# ------------------------------------------------------------
# Esta tabla muestra únicamente los pedidos donde la fórmula
# no coincide con el monto_total registrado.
inconsistencias_montos = orders[filas_inconsistentes]
# Seleccionar las columnas necesarias para revisar cada caso.
inconsistencias_montos = inconsistencias_montos[
[
'id_pedido',
'cantidad',
'precio_unitario',
'monto_descuento',
'monto_total',
'monto_total_calculado',
'diferencia_monto'
]
]
# ------------------------------------------------------------
# 5. MOSTRAR EL REPORTE
# ------------------------------------------------------------
print('----- REPORTE DE CONSISTENCIA DE MONTOS -----')
# Mostrar la cantidad total de filas revisadas.
print('\nCantidad total de pedidos revisados:')
print(orders.shape[0])
# Mostrar la cantidad de filas inconsistentes.
print('\nCantidad de pedidos con montos inconsistentes:')
print(inconsistencias_montos.shape[0])
# Mostrar las primeras 10 inconsistencias para investigarlas.
print('\nPrimeras 10 filas con montos inconsistentes:')
print(inconsistencias_montos.head(10))----- REPORTE DE CONSISTENCIA DE MONTOS ----- Cantidad total de pedidos revisados: 25100 Cantidad de pedidos con montos inconsistentes: 0 Primeras 10 filas con montos inconsistentes: Empty DataFrame Columns: [id_pedido, cantidad, precio_unitario, monto_descuento, monto_total, monto_total_calculado, diferencia_monto] Index: []
Celda 31 · código documentado
# ============================================================
# ELIMINAR DUPLICADOS DEL DATASET ORDERS
# ============================================================
# ------------------------------------------------------------
# 1. REVISAR DUPLICADOS COMPLETOS
# ------------------------------------------------------------
# duplicated() revisa si una fila completa aparece repetida.
#
# keep=False marca como True todas las apariciones de una fila
# repetida, incluida la primera.
filas_duplicadas_completas = orders.duplicated(
keep=False
)
# Mostrar cuántas filas participan en duplicados completos.
print('Cantidad de filas duplicadas completamente:')
print(filas_duplicadas_completas.sum())
# Mostrar algunos ejemplos.
print('\nEjemplos de duplicados completos:')
print(orders[filas_duplicadas_completas].head())
# ------------------------------------------------------------
# 2. REVISAR DUPLICADOS POR ID_PEDIDO
# ------------------------------------------------------------
# id_pedido debe identificar un pedido individual.
#
# keep=False marca todos los registros cuyo id_pedido aparece
# más de una vez.
ids_pedido_duplicados = orders['id_pedido'].duplicated(
keep=False
)
# Mostrar cuántas filas pertenecen a IDs repetidos.
print('\nCantidad de filas con id_pedido repetido:')
print(ids_pedido_duplicados.sum())
# Mostrar algunos ejemplos de IDs repetidos.
print('\nEjemplos de id_pedido repetidos:')
print(
orders[ids_pedido_duplicados]
.sort_values('id_pedido')
.head(10)
)
# ------------------------------------------------------------
# 3. ELIMINAR DUPLICADOS COMPLETOS
# ------------------------------------------------------------
# drop_duplicates() elimina las filas que son idénticas
# en todas sus columnas.
#
# keep='first' conserva la primera aparición y elimina
# las repeticiones posteriores.
orders = orders.drop_duplicates(
keep='first'
)
# ------------------------------------------------------------
# 4. ELIMINAR REPETICIONES DEL ID_PEDIDO
# ------------------------------------------------------------
# subset=['id_pedido'] indica que la revisión se realiza
# únicamente usando la columna id_pedido.
#
# keep='first' conserva el primer registro de cada pedido
# y elimina cualquier repetición posterior.
orders = orders.drop_duplicates(
subset=['id_pedido'],
keep='first'
)
# ------------------------------------------------------------
# 5. REINICIAR EL ÍNDICE
# ------------------------------------------------------------
# Después de eliminar filas, el índice puede conservar saltos.
#
# reset_index(drop=True) crea un índice nuevo:
# 0, 1, 2, 3, ...
#
# drop=True evita que el índice anterior se convierta
# en una columna adicional.
orders = orders.reset_index(
drop=True
)
# ------------------------------------------------------------
# 6. COMPROBAR QUE YA NO HAY DUPLICADOS
# ------------------------------------------------------------
print('\n----- RESULTADO FINAL -----')
resumen_calidad(orders)
Cantidad de filas duplicadas completamente:
200
Ejemplos de duplicados completos:
id_pedido id_usuario fecha_hora_pedido pais dispositivo \
734 order_734 user_6347 2025-05-02 Colombia desktop
812 order_812 user_1530 2025-03-31 Argentina desktop
974 order_974 user_3262 2025-05-04 Argentina desktop
1361 order_1361 user_507 2025-06-27 Mexico desktop
1735 order_1735 user_728 2025-05-13 Mexico mobile
fuente_referencia nombre_producto categoria_producto cantidad \
734 organic Blender-XL-Red Hogar 1.0
812 organic Tablet-Standard-64GB Electronica 1.0
974 paid_search Blender-XL-Red Hogar 2.0
1361 paid_search Laptop-Gaming-16GB Electronica 2.0
1735 social Tablet-Standard-64GB Electronica 2.0
precio_unitario monto_descuento monto_total monto_total_calculado \
734 58.00 0.0 58.00 58.00
812 167.32 10.0 157.32 157.32
974 268.58 5.0 532.16 532.16
1361 239.61 0.0 479.22 479.22
1735 419.04 0.0 838.08 838.08
diferencia_monto
734 0.0
812 0.0
974 0.0
1361 0.0
1735 0.0
Cantidad de filas con id_pedido repetido:
200
Ejemplos de id_pedido repetidos:
id_pedido id_usuario fecha_hora_pedido pais dispositivo \
25023 order_10082 user_690 2025-02-17 Argentina desktop
10082 order_10082 user_690 2025-02-17 Argentina desktop
25037 order_10709 user_6783 2025-02-16 Colombia mobile
10709 order_10709 user_6783 2025-02-16 Colombia mobile
25065 order_10829 user_7697 2025-01-23 Argentina mobile
10829 order_10829 user_7697 2025-01-23 Argentina mobile
10856 order_10856 user_1899 2025-03-18 Colombia desktop
25044 order_10856 user_1899 2025-03-18 Colombia desktop
11449 order_11449 user_1480 2025-03-21 Mexico desktop
25083 order_11449 user_1480 2025-03-21 Mexico desktop
fuente_referencia nombre_producto categoria_producto cantidad \
25023 social Sneakers-Urban-42 Moda 2.0
10082 social Sneakers-Urban-42 Moda 2.0
25037 organic Jacket-Winter-M Moda 1.0
10709 organic Jacket-Winter-M Moda 1.0
25065 social Phone-Pro-128GB Electronica 2.0
10829 social Phone-Pro-128GB Electronica 2.0
10856 social Laptop-Gaming-16GB Electronica 1.0
25044 social Laptop-Gaming-16GB Electronica 1.0
11449 paid_search Vacuum-Pro-Black Hogar 1.0
25083 paid_search Vacuum-Pro-Black Hogar 1.0
precio_unitario monto_descuento monto_total monto_total_calculado \
25023 221.34 0.0 442.67 442.68
10082 221.34 0.0 442.67 442.68
25037 170.10 10.0 160.10 160.10
10709 170.10 10.0 160.10 160.10
25065 115.54 0.0 231.08 231.08
10829 115.54 0.0 231.08 231.08
10856 72.53 0.0 72.53 72.53
25044 72.53 0.0 72.53 72.53
11449 60.55 5.0 55.55 55.55
25083 60.55 5.0 55.55 55.55
diferencia_monto
25023 -0.01
10082 -0.01
25037 0.00
10709 0.00
25065 0.00
10829 0.00
10856 0.00
25044 0.00
11449 0.00
25083 0.00
----- RESULTADO FINAL -----
----- INFORMACIÓN GENERAL -----
Número de filas: 25000
Número de columnas: 14
----- NOMBRES DE COLUMNAS -----
Index(['id_pedido', 'id_usuario', 'fecha_hora_pedido', 'pais', 'dispositivo',
'fuente_referencia', 'nombre_producto', 'categoria_producto',
'cantidad', 'precio_unitario', 'monto_descuento', 'monto_total',
'monto_total_calculado', 'diferencia_monto'],
dtype='object')
----- TIPOS DE DATOS -----
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 25000 entries, 0 to 24999
Data columns (total 14 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 id_pedido 25000 non-null object
1 id_usuario 25000 non-null object
2 fecha_hora_pedido 25000 non-null datetime64[ns]
3 pais 24700 non-null object
4 dispositivo 24980 non-null object
5 fuente_referencia 24970 non-null object
6 nombre_producto 24970 non-null object
7 categoria_producto 24920 non-null object
8 cantidad 24946 non-null float64
9 precio_unitario 24950 non-null float64
10 monto_descuento 24950 non-null float64
11 monto_total 24996 non-null float64
12 monto_total_calculado 24946 non-null float64
13 diferencia_monto 24946 non-null float64
dtypes: datetime64[ns](1), float64(6), object(7)
memory usage: 2.7+ MB
None
----- VALORES FALTANTES -----
pais 300
dispositivo 20
fuente_referencia 30
nombre_producto 30
categoria_producto 80
cantidad 54
precio_unitario 50
monto_descuento 50
monto_total 4
monto_total_calculado 54
diferencia_monto 54
dtype: int64
----- FILAS DUPLICADAS COMPLETAMENTE -----
Cantidad de filas duplicadas: 0
----- DUPLICADOS EN ID_PEDIDO -----
Cantidad de id_pedido repetidos: 0
Cantidad de id_pedido únicos: 25000
----- PRIMERAS CINCO FILAS -----
id_pedido id_usuario fecha_hora_pedido pais dispositivo \
0 order_0 user_6993 2025-05-22 Argentina desktop
1 order_1 user_1329 2025-06-15 Mexico desktop
2 order_2 user_3194 2025-05-02 Argentina desktop
3 order_3 user_4510 2025-06-09 Colombia mobile
4 order_4 user_5044 2025-03-30 Argentina desktop
fuente_referencia nombre_producto categoria_producto cantidad \
0 organic Jacket-Winter-M Moda 2.0
1 paid_search Tablet-Standard-64GB Electronica 1.0
2 social Blender-XL-Red Hogar 2.0
3 social Tablet-Standard-64GB Electronica 1.0
4 paid_search Blender-XL-Red Hogar 1.0
precio_unitario monto_descuento monto_total monto_total_calculado \
0 332.69 0.0 665.37 665.38
1 176.86 5.0 171.86 171.86
2 102.99 10.0 195.99 195.98
3 257.87 15.0 242.87 242.87
4 336.28 0.0 336.28 336.28
diferencia_monto
0 -0.01
1 0.00
2 0.01
3 0.00
4 0.00
----- VALORES UNICOS EN CADA COLUMNA -----
id_pedido 25000
id_usuario 7642
fecha_hora_pedido 181
pais 6
dispositivo 2
fuente_referencia 3
nombre_producto 7
categoria_producto 3
cantidad 4
precio_unitario 19543
monto_descuento 4
monto_total 21532
monto_total_calculado 20942
diferencia_monto 32
dtype: int64
----- DETALLE DE COLUMNAS CATEGÓRICAS -----
Columna: id_pedido
order_8667 1
order_18261 1
order_4740 1
order_15476 1
order_6603 1
..
order_19233 1
order_14754 1
order_13009 1
order_22781 1
order_5482 1
Name: id_pedido, Length: 25000, dtype: int64
Columna: id_usuario
user_7769 11
user_5748 10
user_3267 10
user_6272 10
user_7975 10
..
user_6627 1
user_2516 1
user_2186 1
user_7818 1
user_7761 1
Name: id_usuario, Length: 7642, dtype: int64
Columna: pais
Colombia 7481
Mexico 7478
Argentina 7259
mexico 863
colombia 823
argentina 796
NaN 300
Name: pais, dtype: int64
Columna: dispositivo
desktop 12711
mobile 12269
NaN 20
Name: dispositivo, dtype: int64
Columna: fuente_referencia
social 8402
organic 8288
paid_search 8280
NaN 30
Name: fuente_referencia, dtype: int64
Columna: nombre_producto
Blender-XL-Red 4179
Jacket-Winter-M 4176
Vacuum-Pro-Black 4176
Sneakers-Urban-42 4148
Laptop-Gaming-16GB 2782
Tablet-Standard-64GB 2769
Phone-Pro-128GB 2740
NaN 30
Name: nombre_producto, dtype: int64
Columna: categoria_producto
Hogar 8346
Moda 8295
Electronica 8279
NaN 80
Name: categoria_producto, dtype: int64
Celda 36 · código documentado
# Se imputan las categorias de producto a continuación dado que se cuenta con la info completa
# Crear una tabla de referencia: para cada producto, obtener su categoría
# set_index convierte 'nombre_producto' en el índice (la clave de búsqueda)
# ['categoria_producto'] extrae solo la columna de categorías
mapa_categoria = catalog.set_index('nombre_producto')['categoria_producto']
# Rellenar únicamente las categorías que están vacías (NaN)
# pedidos['nombre_producto'].map(mapa_categoria) busca la categoría de cada producto
# fillna reemplaza solo los valores faltantes, dejando intactos los que ya existen
orders['categoria_producto'] = orders['categoria_producto'].fillna(
orders['nombre_producto'].map(mapa_categoria))
orders['categoria_producto'] = orders['categoria_producto'].replace({
'Electrónica':'Electronica'})
# Se estandarizó la categoria Electronica
orders.info()
orders[orders['categoria_producto'].isna()]
orders['categoria_producto'].value_counts(dropna=False)<class 'pandas.core.frame.DataFrame'> RangeIndex: 25000 entries, 0 to 24999 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id_pedido 25000 non-null object 1 id_usuario 25000 non-null object 2 fecha_hora_pedido 25000 non-null datetime64[ns] 3 pais 24700 non-null object 4 dispositivo 24980 non-null object 5 fuente_referencia 24970 non-null object 6 nombre_producto 24970 non-null object 7 categoria_producto 24970 non-null object 8 cantidad 24946 non-null float64 9 precio_unitario 24950 non-null float64 10 monto_descuento 24950 non-null float64 11 monto_total 24996 non-null float64 12 monto_total_calculado 24946 non-null float64 13 diferencia_monto 24946 non-null float64 dtypes: datetime64[ns](1), float64(6), object(7) memory usage: 2.7+ MB
Hogar 8355 Moda 8324 Electronica 8291 NaN 30 Name: categoria_producto, dtype: int64
Celda 38 · código documentado
# Unificar la escritura de la categoría de tecnología
# En algunos registros aparece 'mexico' y en otros 'Mexico'
# replace cambia todos los nombres a un solo estandar dado
orders['pais'] = orders['pais'].replace({
'mexico':'Mexico', 'colombia':'Colombia', 'argentina':'Argentina'})
orders['pais'].value_counts(dropna=False)Mexico 8341 Colombia 8304 Argentina 8055 NaN 300 Name: pais, dtype: int64
Celda 41 · código documentado
# ============================================================
# FUNCIÓN PARA SEPARAR FILAS CON VALORES NULOS
# ============================================================
def separar_valores_nulos(df, columnas_importantes):
# --------------------------------------------------------
# 1. IDENTIFICAR FILAS QUE TIENEN ALGÚN VALOR NULO
# --------------------------------------------------------
# df[columnas_importantes] selecciona solo las columnas
# que deben contener datos.
#
# isna() marca con True los valores NaN.
#
# any(axis=1) revisa cada fila:
# devuelve True si al menos una de las columnas tiene NaN.
filas_con_nulos = df[columnas_importantes].isna().any(axis=1)
# --------------------------------------------------------
# 2. CREAR EL DATASET AUDITABLE
# --------------------------------------------------------
# Guardar todas las filas que tienen por lo menos un NaN.
#
# copy() crea una copia independiente del DataFrame.
datos_auditables_nulos = df[filas_con_nulos].copy()
# --------------------------------------------------------
# 3. CONSERVAR SOLO FILAS COMPLETAS
# --------------------------------------------------------
# El símbolo ~ significa "NO".
#
# Por eso, ~filas_con_nulos conserva las filas que no tienen
# valores nulos en las columnas importantes.
datos_sin_nulos = df[~filas_con_nulos].copy()
# --------------------------------------------------------
# 4. REINICIAR EL ÍNDICE DEL DATASET PRINCIPAL
# --------------------------------------------------------
# Crea una nueva numeración consecutiva: 0, 1, 2, 3, ...
#
# drop=True evita guardar el índice antiguo como columna.
datos_sin_nulos = datos_sin_nulos.reset_index(drop=True)
# --------------------------------------------------------
# 5. MOSTRAR UN RESUMEN
# --------------------------------------------------------
print('----- RESUMEN DE SEPARACIÓN DE NULOS -----')
print('\nFilas originales:')
print(df.shape[0])
print('\nFilas enviadas al dataset auditable:')
print(datos_auditables_nulos.shape[0])
print('\nFilas que permanecen en el dataset principal:')
print(datos_sin_nulos.shape[0])
print('\nValores nulos encontrados en el dataset auditable:')
print(datos_auditables_nulos[columnas_importantes].isna().sum())
# --------------------------------------------------------
# 6. DEVOLVER LOS DOS DATASETS
# --------------------------------------------------------
# return entrega los dos DataFrames que creó la función.
return datos_sin_nulos, datos_auditables_nulos
Celda 42 · código documentado
# La función devuelve dos resultados:
#
# 1. orders: filas completas que se conservarán para el análisis.
# 2. orders_auditable_nulos: filas con NaN para trazabilidad.
orders, orders_auditable_nulos = separar_valores_nulos(
orders,
columnas_importantes_orders
)
## Definir la columna obligatoria para el análisis de marketing.
#
# canal es necesario para calcular gasto por canal.
columnas_importantes_marketing = [
'canal'
]
# La función devuelve dos resultados:
#
# 1. marketing: registros que tienen canal identificado.
# 2. marketing_auditable_nulos: registros cuyo canal es NaN.
marketing, marketing_auditable_nulos = separar_valores_nulos(
marketing,
columnas_importantes_marketing
)----- RESUMEN DE SEPARACIÓN DE NULOS ----- Filas originales: 25000 Filas enviadas al dataset auditable: 400 Filas que permanecen en el dataset principal: 24600 Valores nulos encontrados en el dataset auditable: pais 300 dispositivo 20 fuente_referencia 30 nombre_producto 30 categoria_producto 30 cantidad 54 precio_unitario 50 monto_descuento 50 monto_total 4 dtype: int64 ----- RESUMEN DE SEPARACIÓN DE NULOS ----- Filas originales: 1620 Filas enviadas al dataset auditable: 101 Filas que permanecen en el dataset principal: 1519 Valores nulos encontrados en el dataset auditable: canal 101 dtype: int64
Celda 49 · código documentado
# exportar datasets
orders.to_csv('orders_clean.csv', index=False)
orders_auditable_nulos.to_csv('orders_auditable_nulos.csv', index=False)
catalog.to_csv('catalog_clean.csv', index=False)
marketing.to_csv('marketing_clean.csv', index=False)
marketing_auditable_nulos.to_csv('marketing_auditable_nulos.csv', index=False)Paso 2. Evaluar rentabilidad y comportamiento de ventas
Se unen pedidos con catálogo por nombre_producto, se calcula el costo de las unidades vendidas y se incorpora el marketing identificado. El resultado es positivo dentro del alcance de estos datos.
| Indicador | Importe | Definición / alcance |
|---|---|---|
| Revenue | $51.836.375,14 | Suma de monto_total en 24.600 pedidos completos. |
| Costo de productos | $43.078.678,80 | Costo unitario del catálogo × cantidad vendida. |
| Profit de Power BI | $8.757.696,34 | Revenue − costo de productos; antes de marketing. |
| Marketing identificado | $2.694.664,43 | Suma del gasto en marketing_clean. |
| Resultado tras costos y marketing | $6.063.031,91 | Revenue − costo − marketing identificado. |
| Margen tras marketing | 11,70 % | Resultado tras marketing / revenue. |
| Margen mostrado en BI | 16,89 % | Profit antes de marketing / revenue. |
| Ticket promedio | $2.107,17 | Revenue por pedido completo. |
El ticket promedio es $2.107,17 y la cantidad media 7,20 unidades por pedido. Laptop-Gaming-16GB acumula 144.160 unidades, una concentración que exige revisar las órdenes de gran volumen.
| Canal | Gasto identificado | Participación |
|---|---|---|
| social | $918.043,21 | 34,1 % |
| organic | $913.533,01 | 33,9 % |
| paid_search | $863.088,21 | 32,0 % |
Ver código y resultados documentados
Celda 53 · código documentado
# Cargar los datos
catalog_clean = pd.read_csv('catalog_clean.csv')
marketing_clean = pd.read_csv('marketing_clean.csv')
orders_clean = pd.read_csv('orders_clean.csv')
# ANÁLISIS DE RENTABILIDAD
# ¿Cuál es el ingreso total (revenue)?
ingreso_total = orders_clean['monto_total'].sum()
# =============================================================================
# ¿Cuál es el costo total?
# Unir catálogo con órdenes para obtener el costo de cada producto vendido
orders_clean_con_costo = orders_clean.merge(
catalog_clean[['nombre_producto', 'costo_unitario']],
on='nombre_producto',
how='left'
)
# Calcular el costo de cada orden y sumar todos
costo_total = (orders_clean_con_costo['costo_unitario'] * orders_clean_con_costo['cantidad']).sum()
# =============================================================================
# ¿Cuánto se ha invertido en marketing?
gasto_marketing_total = marketing_clean['gasto'].sum()
# =============================================================================
# ¿El negocio es rentable? (calcular profit)
# =============================================================================
profit = ingreso_total - costo_total - gasto_marketing_total
margen_profit = (profit / ingreso_total) * 100
# =============================================================================
# Resultados
# =============================================================================
print("="*60)
print("RESULTADOS")
print("="*60)
print(f"Ingreso total (revenue): ${ingreso_total:,.2f}")
print(f"Costo total: ${costo_total:,.2f}")
print(f"Gasto en marketing: ${gasto_marketing_total:,.2f}")
print(f"Profit: ${profit:,.2f}")
print(f"Margen de profit: {margen_profit:.2f}%")
print("="*60)
if profit > 0:
print("El negocio es rentable")
else:
print("El negocio no es rentable")
============================================================ RESULTADOS ============================================================ Ingreso total (revenue): $51,836,375.14 Costo total: $43,078,678.80 Gasto en marketing: $2,694,664.43 Profit: $6,063,031.91 Margen de profit: 11.70% ============================================================ El negocio es rentable
Celda 54 · código documentado
# =============================================================================
# PARTE 2: COMPORTAMIENTO DE VENTAS
# =============================================================================
# =============================================================================
# ¿Cuál es el ticket promedio por orden?
# Ticket promedio = monto total de todas las órdenes / número de órdenes
ticket_promedio = orders_clean['monto_total'].mean()
# =============================================================================
# ¿Cuál es la cantidad promedio de productos por orden?
# Sumamos todas las cantidades y dividimos entre el número de órdenes
cantidad_promedio = orders_clean['cantidad'].mean()
# =============================================================================
# ¿Cuál es el producto más vendido?
# Agrupamos por producto y sumamos las cantidades
ventas_por_producto = orders_clean.groupby('nombre_producto')['cantidad'].sum()
producto_mas_vendido = ventas_por_producto.idxmax()
cantidad_producto_mas_vendido = ventas_por_producto.max()
# =============================================================================
# ¿Cuánto se ha gastado en marketing por canal?
# Agrupamos por canal y sumamos los gastos
gasto_por_canal = marketing_clean.groupby('canal')['gasto'].sum().sort_values(ascending=False)
# =============================================================================
# Resultados
print("="*60)
print("PARTE 2: COMPORTAMIENTO DE VENTAS")
print("="*60)
print("\nTICKET PROMEDIO POR ORDEN")
print(f" Ticket promedio: ${ticket_promedio:,.2f}")
print("\nCANTIDAD PROMEDIO DE PRODUCTOS POR ORDEN")
print(f" Cantidad promedio: {cantidad_promedio:.2f} productos")
print("\nPRODUCTO MÁS VENDIDO")
print("top 5 productos más vendidos")
top_5_productos = orders_clean.groupby('nombre_producto')['cantidad'].sum().sort_values(ascending=False).head(5)
print(top_5_productos)
print(" ____________________________ ")
print(f"Producto mas vendido: {producto_mas_vendido}")
print(f"Cantidad vendida: {cantidad_producto_mas_vendido:,.0f} unidades")
print("\nGASTO EN MARKETING POR CANAL")
for canal, gasto in gasto_por_canal.items():
porcentaje = (gasto / gasto_por_canal.sum()) * 100 # gasto = el gasto de UN canal y gasto_por_canal.sum() = la suma de TODOS los canales
print(f" {canal}: ${gasto:,.2f} ({porcentaje:.1f}%)")
print("\n" + "="*60)============================================================ PARTE 2: COMPORTAMIENTO DE VENTAS ============================================================ TICKET PROMEDIO POR ORDEN Ticket promedio: $2,107.17 CANTIDAD PROMEDIO DE PRODUCTOS POR ORDEN Cantidad promedio: 7.20 productos PRODUCTO MÁS VENDIDO top 5 productos más vendidos nombre_producto Laptop-Gaming-16GB 144160.0 Vacuum-Pro-Black 6203.0 Jacket-Winter-M 6185.0 Blender-XL-Red 6184.0 Sneakers-Urban-42 6085.0 Name: cantidad, dtype: float64 ____________________________ Producto mas vendido: Laptop-Gaming-16GB Cantidad vendida: 144,160 unidades GASTO EN MARKETING POR CANAL social: $918,043.21 (34.1%) organic: $913,533.01 (33.9%) paid_search: $863,088.21 (32.0%) ============================================================
Paso 3. Analizar el funnel de conversión con SQL
Se consulta events y se cuenta COUNT(DISTINCT id_usuario) por evento. Con CTE, subconsultas y NULLIF se calculan razones entre etapas y porcentajes complementarios.
| Transición del funnel | Usuarios de etapa inicial → siguiente | Conversión | Caída aparente |
|---|---|---|---|
first_visit → add_to_cart | 7.796 → 7.634 | 97,92 % | 2,08 % |
add_to_cart → select_item | 7.634 → 7.582 | 99,32 % | 0,68 % |
select_item → begin_checkout | 7.582 → 7.208 | 95,07 % | 4,93 % |
begin_checkout → add_payment_info | 7.208 → 6.250 | 86,71 % | 13,29 % |
add_payment_info → purchase | 6.250 → 6.240 | 99,84 % | 0,16 % |
La razón final es 6.240 / 7.796 = 80,04 %. La mayor caída aparente ocurre de begin_checkout a add_payment_info: 13,29 %, con una diferencia de 958 en los conteos.
Ver código y resultados documentados
Celda 69 · código documentado
# PARTE 1: Totales del funnel
# ======================
# PARTE 1: TOTALES DEL FUNNEL
# =================================
query_totals = '''
-- Seleccionamos el nombre de cada tipo de evento
SELECT nombre_evento,
-- Contamos usuarios únicos que realizaron cada evento
COUNT(DISTINCT id_usuario) AS usuarios
-- Indicamos que los datos se obtienen de la tabla events
FROM events
-- Agrupamos los resultados por tipo de evento
GROUP BY nombre_evento
-- Ordenamos las etapas por cantidad de usuarios, de mayor a menor
ORDER BY usuarios DESC;
'''
# Ejecutamos la consulta SQL y guardamos el resultado en un DataFrame
totals = pd.read_sql(query_totals, con=engine)
# Mostramos la tabla con los totales de usuarios por etapa
totals| nombre_evento | usuarios | |
|---|---|---|
| 0 | first_visit | 7796 |
| 1 | add_to_cart | 7634 |
| 2 | select_item | 7582 |
| 3 | begin_checkout | 7208 |
| 4 | add_payment_info | 6250 |
| 5 | purchase | 6240 |
Celda 71 · código documentado
# PARTE 2: ANALISIS DE CONVERSION Y DROP-OFF
# =================================
query_conversion = '''
-- Contamos los usuarios únicos de cada evento
WITH cte_eventos AS (
-- Agrupamos por el nombre de cada evento
SELECT
nombre_evento,
-- Contamos usuarios únicos que realizaron cada evento
COUNT(DISTINCT id_usuario) AS usuarios
-- Usamos la tabla events
FROM events
-- Creamos un total para cada nombre de evento
GROUP BY nombre_evento
)
-- Calculamos las tasas entre las etapas del funnel
SELECT
-- Conversión de first_visit a add_to_cart
ROUND(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_to_cart'
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'first_visit'
),
0
),
2
) AS conversion_first_visit_add_to_cart,
-- Drop-off de first_visit a add_to_cart
ROUND(
(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'first_visit'
)
-
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_to_cart'
)
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'first_visit'
),
0
),
2
) AS dropoff_first_visit_add_to_cart,
-- Conversión de add_to_cart a select_item
ROUND(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'select_item'
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_to_cart'
),
0
),
2
) AS conversion_add_to_cart_select_item,
-- Drop-off de add_to_cart a select_item
ROUND(
(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_to_cart'
)
-
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'select_item'
)
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_to_cart'
),
0
),
2
) AS dropoff_add_to_cart_select_item,
-- Conversión de select_item a begin_checkout
ROUND(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'begin_checkout'
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'select_item'
),
0
),
2
) AS conversion_select_item_begin_checkout,
-- Drop-off de select_item a begin_checkout
ROUND(
(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'select_item'
)
-
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'begin_checkout'
)
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'select_item'
),
0
),
2
) AS dropoff_select_item_begin_checkout,
-- Conversión de begin_checkout a add_payment_info
ROUND(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_payment_info'
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'begin_checkout'
),
0
),
2
) AS conversion_begin_checkout_add_payment_info,
-- Drop-off de begin_checkout a add_payment_info
ROUND(
(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'begin_checkout'
)
-
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_payment_info'
)
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'begin_checkout'
),
0
),
2
) AS dropoff_begin_checkout_add_payment_info,
-- Conversión de add_payment_info a purchase
ROUND(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'purchase'
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_payment_info'
),
0
),
2
) AS conversion_add_payment_info_purchase,
-- Drop-off de add_payment_info a purchase
ROUND(
(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_payment_info'
)
-
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'purchase'
)
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'add_payment_info'
),
0
),
2
) AS dropoff_add_payment_info_purchase,
-- Tasa de conversión final:
-- usuarios del último paso / usuarios del primer paso × 100
ROUND(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'purchase'
)::numeric
* 100
/
NULLIF(
(
SELECT usuarios
FROM cte_eventos
WHERE nombre_evento = 'first_visit'
),
0
),
2
) AS conversion_final
'''
# Ejecutamos la consulta SQL
conversion = pd.read_sql(query_conversion, con=engine)
# Mostramos únicamente los porcentajes calculados
conversion| conversion_first_visit_add_to_cart | dropoff_first_visit_add_to_cart | conversion_add_to_cart_select_item | dropoff_add_to_cart_select_item | conversion_select_item_begin_checkout | dropoff_select_item_begin_checkout | conversion_begin_checkout_add_payment_info | dropoff_begin_checkout_add_payment_info | conversion_add_payment_info_purchase | dropoff_add_payment_info_purchase | conversion_final | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 97.92 | 2.08 | 99.32 | 0.68 | 95.07 | 4.93 | 86.71 | 13.29 | 99.84 | 0.16 | 80.04 |
Paso 4. Medir retención semanal por cohortes
Las tablas users y user_activity se unen con LEFT JOIN. DATE_TRUNC define la semana de registro y COUNT(DISTINCT CASE WHEN ...) identifica usuarios activos en ventanas 1–7, 8–14, 15–21 y 22–28 días.
| Semana posterior al registro | Activos retenidos / 8.000 | Tasa agregada ponderada |
|---|---|---|
| Semana 1 | 3.360 | 42,00 % |
| Semana 2 | 3.355 | 41,94 % |
| Semana 3 | 3.350 | 41,88 % |
| Semana 4 | 3.250 | 40,63 % |
La tasa agregada de semana 4 es 40,63 %: 3.250 activos sobre 8.000 usuarios de las cohortes. El mapa de calor conserva las tasas originales de cada cohorte, no solo un promedio global.
Esta retención semanal mide actividad de usuarios. La matriz mensual de Power BI mide ingresos atribuidos a cohortes de compra: son poblaciones, unidades y ventanas diferentes.
Ver código y resultados documentados
Celda 82 · código documentado
# RETENCIÓN SEMANAL POR COHORTES
# ===============================
query_cohort_retention_final = '''
-- PASO 1: Identificamos la cohorte semanal de cada usuario
WITH cohortes AS (
-- Seleccionamos el identificador de cada usuario
SELECT
u.id_usuario,
-- Convertimos la fecha de registro al inicio de su semana
DATE_TRUNC(
'week',
CAST(u.fecha_registro AS DATE)
) AS cohorte_semana
-- Utilizamos la tabla users
FROM users AS u
),
-- PASO 2: Relacionamos cada usuario con su actividad semanal
actividad_semanal AS (
-- Seleccionamos la cohorte semanal del usuario
SELECT
c.cohorte_semana,
-- Conservamos el identificador del usuario
c.id_usuario,
-- Tomamos la semana transcurrida desde el registro
ua.dias_despues_registro,
-- Conservamos si el usuario estuvo activo
ua.activo
-- Comenzamos desde las cohortes
FROM cohortes AS c
-- Unimos la actividad con el usuario correspondiente
LEFT JOIN user_activity AS ua
ON c.id_usuario = ua.id_usuario
),
-- PASO 3: Calculamos los usuarios iniciales y los retenidos
retencion AS (
-- Agrupamos los resultados por cohorte semanal
SELECT
cohorte_semana,
-- Contamos los usuarios iniciales de cada cohorte
COUNT(DISTINCT id_usuario) AS clientes_iniciales,
-- Contamos usuarios activos durante la semana 1
COUNT(DISTINCT CASE
WHEN dias_despues_registro BETWEEN 1 AND 7
AND activo = 1
THEN id_usuario
END) AS retenido_w1,
-- Contamos usuarios activos durante la semana 2
COUNT(DISTINCT CASE
WHEN dias_despues_registro BETWEEN 8 AND 14
AND activo = 1
THEN id_usuario
END) AS retenido_w2,
-- Contamos usuarios activos durante la semana 3
COUNT(DISTINCT CASE
WHEN dias_despues_registro BETWEEN 15 AND 21
AND activo = 1
THEN id_usuario
END) AS retenido_w3,
-- Contamos usuarios activos durante la semana 4
COUNT(DISTINCT CASE
WHEN dias_despues_registro BETWEEN 22 AND 28
AND activo = 1
THEN id_usuario
END) AS retenido_w4
-- Utilizamos la actividad semanal
FROM actividad_semanal
-- Agrupamos por cohorte semanal
GROUP BY cohorte_semana
)
-- PASO 4: Mostramos la tabla final de retención
SELECT
-- Mostramos la semana de registro de la cohorte
TO_CHAR(cohorte_semana, 'YYYY-MM-DD') AS cohorte,
-- Mostramos los usuarios iniciales
clientes_iniciales,
-- Mostramos los usuarios retenidos en cada semana
retenido_w1,
retenido_w2,
retenido_w3,
retenido_w4,
-- Calculamos el porcentaje de retención de la semana 1
ROUND(
retenido_w1::numeric
/ NULLIF(clientes_iniciales, 0)
* 100,
2
) AS retencion_w1_pct,
-- Calculamos el porcentaje de retención de la semana 2
ROUND(
retenido_w2::numeric
/ NULLIF(clientes_iniciales, 0)
* 100,
2
) AS retencion_w2_pct,
-- Calculamos el porcentaje de retención de la semana 3
ROUND(
retenido_w3::numeric
/ NULLIF(clientes_iniciales, 0)
* 100,
2
) AS retencion_w3_pct,
-- Calculamos el porcentaje de retención de la semana 4
ROUND(
retenido_w4::numeric
/ NULLIF(clientes_iniciales, 0)
* 100,
2
) AS retencion_w4_pct
-- Utilizamos la tabla de retención
FROM retencion
-- Ordenamos las cohortes desde la más antigua
ORDER BY cohorte_semana;
'''
# Ejecutamos la consulta SQL
cohorte_final = pd.read_sql(
query_cohort_retention_final,
con=engine
)
# Mostramos la tabla final de retención semanal
cohorte_final
| cohorte | clientes_iniciales | retenido_w1 | retenido_w2 | retenido_w3 | retenido_w4 | retencion_w1_pct | retencion_w2_pct | retencion_w3_pct | retencion_w4_pct | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2024-12-30 | 236 | 99 | 91 | 95 | 97 | 41.95 | 38.56 | 40.25 | 41.10 |
| 1 | 2025-01-06 | 351 | 145 | 157 | 148 | 138 | 41.31 | 44.73 | 42.17 | 39.32 |
| 2 | 2025-01-13 | 362 | 158 | 138 | 152 | 148 | 43.65 | 38.12 | 41.99 | 40.88 |
| 3 | 2025-01-20 | 394 | 174 | 156 | 157 | 159 | 44.16 | 39.59 | 39.85 | 40.36 |
| 4 | 2025-01-27 | 373 | 155 | 177 | 149 | 164 | 41.55 | 47.45 | 39.95 | 43.97 |
| 5 | 2025-02-03 | 405 | 164 | 177 | 184 | 168 | 40.49 | 43.70 | 45.43 | 41.48 |
| 6 | 2025-02-10 | 364 | 153 | 145 | 164 | 137 | 42.03 | 39.84 | 45.05 | 37.64 |
| 7 | 2025-02-17 | 338 | 159 | 136 | 135 | 138 | 47.04 | 40.24 | 39.94 | 40.83 |
| 8 | 2025-02-24 | 353 | 149 | 148 | 151 | 145 | 42.21 | 41.93 | 42.78 | 41.08 |
| 9 | 2025-03-03 | 363 | 144 | 158 | 146 | 147 | 39.67 | 43.53 | 40.22 | 40.50 |
| 10 | 2025-03-10 | 366 | 154 | 161 | 151 | 144 | 42.08 | 43.99 | 41.26 | 39.34 |
| 11 | 2025-03-17 | 394 | 162 | 170 | 175 | 157 | 41.12 | 43.15 | 44.42 | 39.85 |
| 12 | 2025-03-24 | 360 | 148 | 145 | 151 | 155 | 41.11 | 40.28 | 41.94 | 43.06 |
| 13 | 2025-03-31 | 361 | 146 | 159 | 150 | 146 | 40.44 | 44.04 | 41.55 | 40.44 |
| 14 | 2025-04-07 | 379 | 174 | 173 | 166 | 177 | 45.91 | 45.65 | 43.80 | 46.70 |
| 15 | 2025-04-14 | 368 | 157 | 164 | 154 | 142 | 42.66 | 44.57 | 41.85 | 38.59 |
| 16 | 2025-04-21 | 389 | 154 | 160 | 153 | 151 | 39.59 | 41.13 | 39.33 | 38.82 |
| 17 | 2025-04-28 | 379 | 168 | 143 | 150 | 138 | 44.33 | 37.73 | 39.58 | 36.41 |
| 18 | 2025-05-05 | 379 | 156 | 151 | 152 | 153 | 41.16 | 39.84 | 40.11 | 40.37 |
| 19 | 2025-05-12 | 364 | 133 | 158 | 156 | 148 | 36.54 | 43.41 | 42.86 | 40.66 |
| 20 | 2025-05-19 | 389 | 166 | 154 | 169 | 165 | 42.67 | 39.59 | 43.44 | 42.42 |
| 21 | 2025-05-26 | 333 | 142 | 134 | 142 | 133 | 42.64 | 40.24 | 42.64 | 39.94 |
Paso 5. Validar la nueva interfaz de checkout
El experimento compara Control con Tratamiento. La hipótesis nula plantea igualdad de tasas de compra; la alternativa bilateral plantea una diferencia.
| Variante | Usuarios | Compras | Conversión |
|---|---|---|---|
| Control | 4.965 | 779 | 15,69 % |
| Tratamiento | 5.035 | 820 | 16,29 % |
Con proportions_ztest(), z = −0,81328 y p = 0,4160585. La diferencia observada es +0,60 puntos porcentuales a favor de Tratamiento, pero no se rechaza H₀ con α = 0,05.
Ver código y resultados documentados
Celda 90 · código documentado
# Número de usuarios convertidos por página
conversiones = experiment_checkout_ui.groupby('variante')['convirtio'].sum()
totales = experiment_checkout_ui.groupby('variante')['convirtio'].count()
# Total de usuarios por página
conteos = [conversiones['control'], conversiones['tratamiento']]
num_observaciones = [totales['control'], totales['tratamiento']]
print("Usuarios convertidos por UI:\n", conteos)
print("\nTotal de usuarios por UI:\n", num_observaciones)Usuarios convertidos por UI: [779, 820] Total de usuarios por UI: [4965, 5035]
Celda 91 · código documentado
# Aplicar prueba
from statsmodels.stats.proportion import proportions_ztest
# Ejecutar el z-test para visualizar resultados
z_stat, p_value = proportions_ztest(conteos, num_observaciones)
print(f"Estadístico z: {z_stat:.5f}")
print(f"Valor P: {p_value:.7f}")
alpha = 0.05 # umbral de significancia
if p_value < alpha:
print("Rechazamos la hipótesis nula: hay evidencia de una diferencia.")
else:
print("No rechazamos la hipótesis nula: no hay evidencia suficiente de una diferencia.")
#---------------------------------------------------------------------------------------------
# Obtener tasa o porcentaje de éxito por grupo
tasa_A = conteos[0] / num_observaciones[0]
tasa_B = conteos[1] / num_observaciones[1]
print(f"\nTasa UI Control: {tasa_A:.2%}")
print(f"Tasa UI Tratamiento: {tasa_B:.2%}")
print(f"Diferencia: {tasa_B - tasa_A:.2%}")
Estadístico z: -0.81328 Valor P: 0.4160585 No rechazamos la hipótesis nula: no hay evidencia suficiente de una diferencia. Tasa UI Control: 15.69% Tasa UI Tratamiento: 16.29% Diferencia: 0.60%
Paso 6. Comunicar el resultado en Power BI
Los CSV limpios alimentan el informe BI. Overview reúne revenue, costos, profit, marketing, ticket, cantidad media, evolución por país y categoría. La vista de detalle baja al producto y a pedidos con pérdidas.
La tabla de catálogo documenta costos y proveedores, y el drill-through permite investigar un producto en Power BI. La matriz mensual agrega revenue por cohorte; no es el mapa semanal de actividad calculado en SQL.
El detalle incluye operaciones de Laptop-Gaming-16GB por 10.000 unidades con profit negativo. Son focos concretos para revisar precio, costo, descuentos y consistencia de cantidades.
Entrega final. Entrega del proyecto final
La documentación reúne 102 celdas de notebook, seis figuras originales de Python/SQL y dos páginas exportadas del dashboard. El repositorio incluye notebook, PBIX y los tres CSV limpios.
Esta página integra el proceso completo y mantiene las salidas originales. Las descargas incorporadas permiten consultar el notebook y el PDF; el archivo Power BI está enlazado al final.
Las credenciales de conexión se excluyen de la copia pública descargable y se sustituyen por una variable de entorno, sin cambiar las consultas ni sus resultados guardados.
Hallazgos y prioridades de negocio
Las métricas financieras, conductuales y experimentales convergen en focos concretos de auditoría e investigación, sin asumir causas ausentes en los datos.
Revenue $51.836.375,14; productos $43.078.678,80; marketing identificado $2.694.664,43.
El costo de productos absorbe aproximadamente 83,11 % del revenue. El resultado medido tras marketing es $6.063.031,91, con margen 11,70 %.
Revisar costo unitario, descuentos y órdenes negativas. Incorporar gastos excluidos y otros costos antes de concluir utilidad neta total.
Laptop-Gaming-16GB: 144.160 unidades. El detalle muestra pedidos por 10.000 unidades, incluido uno con −$2.375.400 de profit antes de marketing.
Hay concentración y operaciones extremas. La consistencia aritmética del monto no certifica que cantidad o precio sean comercialmente correctos.
Auditar precios, costos, cantidades y posibles errores de carga. No atribuir subsidios de membresía sin ingresos de suscripción y reglas comerciales.
Ratio begin_checkout → add_payment_info: 86,71 %; caída aparente de 13,29 %.
Ese tramo presenta la mayor diferencia relativa entre conteos de eventos, pero no revela por sí solo por qué ocurre.
Validar secuencia por usuario/sesión y revisar métodos de pago, mensajes, errores y costos mostrados en checkout.
22 cohortes de registro; tasas agregadas 42,00 %, 41,94 %, 41,88 % y 40,63 % en semanas 1–4.
El patrón es de actividad semanal; no mide abandono permanente, compras continuas ni la recurrencia monetaria mensual del dashboard.
Comparar segmentos y horizontes completos. Diseñar hipótesis de reactivación y verificar si generan actividad incremental.
Control 15,69 %; Tratamiento 16,29 %; diferencia +0,60 pp. z = −0,81328; p = 0,4160585.
La diferencia observada no es suficiente para rechazar igualdad de tasas al nivel 0,05.
No adoptar el cambio únicamente por conversión. Definir efecto mínimo relevante, potencia, costos y métricas de experiencia antes de repetir el experimento.
Power BI muestra revenue YTD de $51,84 millones y una matriz de ingresos mensuales por cohorte.
Un acumulado creciente no demuestra aceleración; los repuntes de revenue pueden estar afectados por pedidos extremos y el mix de productos.
Separar ventas ordinarias de operaciones excepcionales y comparar tasas o compradores únicos, no solo importes acumulados.
Lectura ejecutiva integrada
Una síntesis que conecta producto, experiencia y medición.
Alcance y límites de las conclusiones
- Los KPIs de ventas usan 24.600 pedidos completos; 400 pedidos permanecen auditables. Los resultados no representan todos los registros originales sin una evaluación de exclusión.
- Marketing_clean excluye 101 registros con canal ausente. Sus importes no entran en el gasto identificado utilizado en el profit de Python; pueden modificar el resultado económico completo.
- Profit antes y después de marketing no es utilidad neta después de todos los costos. El dataset no incluye el ingreso de suscripciones ni permite probar que estas financien pérdidas de producto.
- Valores de 10.000 o 20.000 unidades pueden influir fuertemente en medias y revenue. No se descartan automáticamente, pero requieren validación comercial y de origen.
- El funnel usa usuarios únicos contados por evento de forma independiente. No comprueba orden temporal, pertenencia a una misma sesión ni intersección entre etapas.
- La retención SQL se define por actividad en semanas posteriores al registro. La matriz de Power BI contiene ingresos por meses de cohorte de compra; no son la misma métrica.
- Una menor actividad en una semana no implica abandono permanente. Debe revisarse completitud de seguimiento y posibilidad de actividad intermitente.
- El test A/B no aporta evidencia significativa con p = 0,4160585. Esto no demuestra equivalencia y no sustituye intervalos, análisis de potencia o efecto mínimo relevante.
- La distribución del presupuesto entre canales no mide retorno, CAC o causalidad de campañas. Las compras extremas pueden distorsionar rankings de países y productos.
- Los descuentos observados de 0, 5, 10 y 15 son importes en la fórmula de monto_total; no se presentan como porcentajes de descuento.
- Las exportaciones BI son estáticas. Drill-through y segmentadores se utilizan en el PBIX, no sobre las imágenes de esta web.
- El contenido utiliza salidas guardadas y cálculos descriptivos verificables; no reejecuta la conexión SQL ni añade pruebas nuevas. La copia descargable sustituye la contraseña por una variable de entorno.
Visualizaciones del proyecto completo
Seis figuras originales del notebook y nueve vistas del informe Power BI: dos páginas completas y siete recortes de sus gráficos y tablas. El panel de ventas contiene cuatro gráficos adicionales dentro de la misma figura.
Rentabilidad del negocio
Figura original de barras con revenue, costo de producto, gasto de marketing identificado y resultado posterior a ambos costos.
El profit de esta figura resta marketing, a diferencia de la tarjeta de Power BI.
Ventas, ingresos y marketing
Panel original con top de unidades, top de ingresos por producto, inversión por canal y comparación de países. Laptop-Gaming-16GB domina el volumen y concentra facturación.
La etiqueta “Top 5 países” conserva el código original; la base limpia solo contiene Argentina, Colombia y México.
Matriz de dispersión de pedidos
Pairplot original de cantidad, precio unitario, descuento e ingreso por pedido. Las cantidades extremas afectan la escala y la interpretación de medias.
Es una muestra de la base de 24.600 pedidos, no todas las observaciones. La asociación visual no implica causalidad.
Ratios de conversión por transición
Barras originales con las cinco razones de conversión calculadas por SQL a partir de usuarios distintos por evento.
Las razones agregadas no garantizan secuencia temporal o que los conjuntos de usuarios sean los mismos.
Caída aparente entre eventos
Figura original de porcentajes de caída, complementarios a los ratios de conversión. El tramo de información de pago destaca como foco de revisión.
El gráfico localiza una diferencia de conteos, no una causa probada de deserción.
Retención semanal de registro
Mapa de calor original con porcentajes por cohorte de registro. Las tasas agregadas ponderadas se sitúan alrededor de 42 % y llegan a 40,63 % en semana 4.
Mide actividad semanal de usuarios; no la matriz mensual de ingresos que aparece en Power BI.
Power BI: Overview completo
Reproducción de la primera página del PDF: indicadores, evolución mensual por país, acumulado de revenue, categorías y catálogo de producto/proveedor.
Los segmentadores son parte de la exportación estática. El profit de BI no resta marketing.
Power BI: Detalle completo
Reproducción de la segunda página del informe. Integra volumen por producto, margen, tabla detallada de órdenes y matriz monetaria por cohorte.
Drill-through y filtros se utilizan en el PBIX; la matriz mide importes, no tasas de retención.
Profit mensual por país
Recorte del informe con la evolución mensual del profit antes de marketing. La narrativa del dashboard destaca un repunte de Argentina y periodos negativos en otros mercados.
Los movimientos requieren revisar composición y pedidos extremos antes de atribuir causas comerciales.
Revenue acumulado del periodo
Serie acumulada de revenue. Resume el aporte mensual a la facturación del periodo representado.
Un acumulado creciente no prueba aceleración del negocio; debe analizarse el incremento de cada mes.
Categorías: volumen económico y margen
Comparación de las categorías Electrónica, Hogar y Moda. La electrónica concentra la facturación y presenta menor margen relativo que las otras categorías en el informe.
La etiqueta original “% de Ganacias” se conserva en la imagen; no demuestra utilidad neta.
Catálogo: producto, costo y proveedor
Tabla de costos unitarios y proveedores que contextualiza el cálculo económico por producto y el análisis de detalle.
El costo de catálogo es una referencia del dataset; no incluye todos los costos logísticos u operativos.
Unidades y margen por producto
Visual del detalle que compara unidades y margen porcentual por producto. La laptop concentra el volumen, pero su margen difiere del de otras referencias.
Descuentos de membresía o ventas mayoristas son hipótesis; no están demostrados por este gráfico.
Órdenes con pérdidas y detalle monetario
Tabla de pedidos con cantidades, precios, revenue, costo y profit. Se observan operaciones de 10.000 unidades y precios inferiores al costo unitario.
Las operaciones deben auditarse antes de concluir subsidios, abuso o errores de plataforma.
Cohortes mensuales de revenue
Matriz de ingresos con seis cohortes mensuales de 2025 y posiciones 0–5. Permite revisar cuándo se atribuye revenue a cada cohorte.
No mide retención de usuarios. Los importes altos en meses posteriores pueden estar influidos por órdenes extremas y diferente seguimiento.
Archivos del proyecto final
El notebook documenta el análisis, el PDF conserva la presentación BI y el PBIX permite explorar el informe. El repositorio reúne además los CSV limpios.
Incluye limpieza, KPIs, SQL, visualizaciones y el contraste A/B. La copia incorporada conserva resultados y sustituye la contraseña de conexión por una variable de entorno.
Exportación de las dos páginas del dashboard. La descarga al inicio entrega el PDF completo incorporado en este HTML.
El PBIX contiene las vistas, segmentadores y navegación a detalle de producto. Está enlazado a Google Drive.