Capítulo 15 de 16 13 secciones 16 min

Proyecto final: ¿movemos clientes a WhatsApp?

Una pregunta de negocio de principio a fin, con el informe de una página que se puede defender en una reunión.

Un análisis completo tiene siete pasos: escribir la pregunta y el criterio antes de mirar, limpiar, describir, comparar con su intervalo, medir el tamaño del efecto, controlar los confusores y traducir a dinero. Aquí WhatsApp cierra 19,37 puntos más que Marketplace, el efecto aguanta dentro de los cuatro segmentos, y vale entre 62.748 y 105.951 soles en año y medio.

Último capítulo con datos 🎓 Vamos a hacer un análisis entero, de la pregunta al informe, usando todo lo del libro.

Y voy a hacerlo en el orden correcto, que es el que casi nunca se sigue: la pregunta y el criterio primero, los datos después.

Paso 1: la pregunta y el criterio, antes de mirar nada

La pregunta. Marketplace es el canal que peor cierra y WhatsApp el que mejor. ¿Vale la pena empujar a los clientes de Marketplace hacia WhatsApp?

El criterio, escrito antes. Me lo tomo en serio si:

  • 📏 La diferencia es de al menos 5 puntos de tasa de cierre. Menos de eso no paga el esfuerzo de cambiar un proceso.
  • 🎯 El intervalo del 95% no toca el cero.
  • 🧩 El efecto aguanta dentro de cada segmento. Si solo existe en el total, es Simpson y no me sirve.

Escribir esto antes es lo que impide el p-hacking del capítulo 14. Si el resultado sale a 4 puntos, ya no puedo decir "bueno, 4 también está bien" 🙅

Paso 2: cargar y limpiar

import pandas as pd
import numpy as np
from scipy import stats

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'


def carga_limpia(url):
    """La misma del capítulo 2."""
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y',
                                  errors='coerce')
        v[col] = f
    return v


v = carga_limpia(URL)

print('ventas: %d | clientes: %d | periodo: %s a %s'
      % (len(v), v['cliente_id'].nunique(),
         v['fecha'].min().date(), v['fecha'].max().date()))
ventas: 3000 | clientes: 617 | periodo: 2025-01-01 a 2026-06-24

Y aquí ya anoto una limitación para el informe: 3.000 ventas de 617 clientes, o sea que las filas no son independientes (capítulo 14).

Paso 3: describir antes de concluir

wa = v[v['canal'] == 'WhatsApp']
mp = v[v['canal'] == 'Marketplace']

for nombre, g in [('WhatsApp', wa), ('Marketplace', mp)]:
    print('%-12s n=%3d | cierre %.4f | monto medio %.2f | mediana %.2f'
          % (nombre, len(g), g['compro'].mean(), g['monto'].mean(),
             g['monto'].median()))
WhatsApp     n=719 | cierre 0.6551 | monto medio 789.14 | mediana 521.76
Marketplace  n=763 | cierre 0.4613 | monto medio 812.27 | mediana 551.29

Primer dato importante y que hay que decir en voz alta: el monto es prácticamente igual en los dos canales (789 contra 812, y las medianas también).

Eso simplifica muchísimo el problema. Solo cambia la tasa de cierre, no el tamaño de la venta, así que todo el efecto se puede traducir a "cuántas ventas más se cierran" 💡

Paso 4: la comparación con su intervalo

dif = wa['compro'].mean() - mp['compro'].mean()
ee = np.sqrt(wa['compro'].var(ddof=1) / len(wa)
             + mp['compro'].var(ddof=1) / len(mp))

print('diferencia: %.4f (%.2f puntos)' % (dif, 100 * dif))
print('intervalo del 95%%: [%.4f, %.4f]' % (dif - 1.96 * ee, dif + 1.96 * ee))
print('valor p: %.3g'
      % stats.ttest_ind(wa['compro'], mp['compro'], equal_var=False).pvalue)
diferencia: 0.1937 (19.37 puntos)
intervalo del 95%: [0.1441, 0.2434]
valor p: 3.51e-14

19,37 puntos, con un intervalo que va de 14,41 a 24,34 y no se acerca al cero ✅

Los dos primeros criterios se cumplen con muchísimo margen: pedía 5 puntos y hay 19, y el peor caso del intervalo sigue siendo 14 puntos.

Paso 5: el tamaño del efecto

def d_de_cohen(a, b):
    na, nb = len(a), len(b)
    juntas = np.sqrt(((na - 1) * a.var(ddof=1) + (nb - 1) * b.var(ddof=1))
                     / (na + nb - 2))
    return (a.mean() - b.mean()) / juntas


print('d de Cohen: %.4f' % d_de_cohen(wa['compro'], mp['compro']))
d de Cohen: 0.3972

0,3972, o sea chico tirando a mediano según los cortes del capítulo 12.

Y esto conviene contarlo con cuidado, porque puede sonar a contradicción: 19 puntos de diferencia y un efecto "chico". Las dos cosas son ciertas. El efecto es chico en desviaciones porque una variable 0/1 tiene una dispersión enorme; y 19 puntos son 19 puntos de negocio 💰

Por eso hay que reportar los dos números, y por eso el que va primero en el informe es el de puntos.

Paso 6: el paso que casi nadie da

Antes de creerme nada, el control de Simpson del capítulo 14. ¿Aguanta el efecto dentro de cada segmento?

for seg in sorted(v['segmento'].unique()):
    a = wa.loc[wa['segmento'] == seg, 'compro']
    b = mp.loc[mp['segmento'] == seg, 'compro']
    d = a.mean() - b.mean()
    e = np.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b))
    print('%-11s WhatsApp %.4f (n=%3d) | Marketplace %.4f (n=%3d) | '
          'dif %+.4f IC [%+.4f, %+.4f]'
          % (seg, a.mean(), len(a), b.mean(), len(b), d,
             d - 1.96 * e, d + 1.96 * e))
Bodega      WhatsApp 0.4545 (n=176) | Marketplace 0.2762 (n=181) | dif +0.1783 IC [+0.0798, +0.2768]
Horeca      WhatsApp 0.6578 (n=187) | Marketplace 0.5050 (n=202) | dif +0.1528 IC [+0.0557, +0.2499]
Mayorista   WhatsApp 0.8187 (n=171) | Marketplace 0.6368 (n=190) | dif +0.1819 IC [+0.0921, +0.2716]
Minimarket  WhatsApp 0.6919 (n=185) | Marketplace 0.4158 (n=190) | dif +0.2761 IC [+0.1792, +0.3730]

Aguanta en los cuatro 🎉

Las diferencias van de +15,28 a +27,61 puntos, y ninguno de los cuatro intervalos toca el cero. Esto no es Simpson: el efecto está dentro de cada grupo, no lo fabrica la mezcla.

Tercer criterio cumplido. Y fíjate en que este paso podría haber tumbado el análisis entero después de cinco pasos de trabajo, que es exactamente por qué se hace 🛡️

Paso 7: traducirlo a dinero

tickets = v.loc[v['compro'] == 1, 'monto_final_facturado']
ticket = tickets.median()

cierres_extra = dif * len(mp)
valor = cierres_extra * ticket

print('ticket mediano de una venta cerrada: %.2f' % ticket)
print('ventas por Marketplace en el periodo: %d' % len(mp))
print('cierres adicionales si tuvieran la tasa de WhatsApp: %.1f' % cierres_extra)
print()
print('valor central:   S/ %.0f' % valor)
print('valor pesimista: S/ %.0f' % ((dif - 1.96 * ee) * len(mp) * ticket))
print('valor optimista: S/ %.0f' % ((dif + 1.96 * ee) * len(mp) * ticket))
ticket mediano de una venta cerrada: 570.61
ventas por Marketplace en el periodo: 763
cierres adicionales si tuvieran la tasa de WhatsApp: 147.8

valor central:   S/ 84349
valor pesimista: S/ 62748
valor optimista: S/ 105951

Entre 62.748 y 105.951 soles en año y medio 💰

Uso la mediana del ticket y no la media a propósito, por lo del capítulo 3: la media está inflada por los mayoristas y daría un número más bonito y menos defendible.

El informe

Todo lo anterior cabe en esto, que es lo único que hay que llevar a la reunión 📄

ApartadoContenido
Pregunta¿Conviene mover clientes de Marketplace a WhatsApp?
HallazgoWhatsApp cierra 19,37 puntos más (65,51% contra 46,13%), IC 95%: 14,41 a 24,34 puntos
RobustezSe mantiene en los cuatro segmentos, de +15,28 a +27,61 puntos, ningún intervalo toca el cero
Tamañod = 0,40; el monto por venta no cambia entre canales (789 contra 812)
ValorEntre S/ 62.748 y S/ 105.951 en 18 meses, con ticket mediano de S/ 570,61
Datos3.000 ventas de 617 clientes, enero 2025 a junio 2026
LímitesEs observacional, no un experimento. Las filas no son independientes. Junio de 2026 está incompleto.
Siguiente pasoPrueba con 100 clientes de Marketplace durante 3 meses

El error que casi me cuesta el informe

Mientras armaba esto escribí el nombre del canal con minúscula, que es como lo escribe medio mundo 🙃

v.groupby('canal')['compro'].mean()['Whatsapp']
KeyError: 'Whatsapp'

Menos mal. Ahora mira exactamente el mismo despiste hecho con un filtro:

print('con la W mal escrita: ', v.loc[v['canal'] == 'Whatsapp', 'compro'].mean())
print('filas que trae:       ', (v['canal'] == 'Whatsapp').sum())
print('bien escrito:         ', v.loc[v['canal'] == 'WhatsApp', 'compro'].mean())
con la W mal escrita:  nan
filas que trae:        0
bien escrito:          0.655076495132128

Cero filas y un nan, sin protestar. Es el mismo error del capítulo 7, y lo repito aquí a propósito porque en un informe de siete pasos hay veinte filtros, y basta con que uno esté mal 😖

Mi regla, después de tropezarme muchas veces: después de cada filtro, imprime cuántas filas quedaron. Una línea que no cuesta nada y que caza esto en el segundo en que pasa.

La limitación que hay que decir en voz alta

Y ahora lo más importante del capítulo, que es lo que el análisis no demuestra 🗣️

Todo esto es observacional. Yo no asigné clientes a canales: ellos eligieron. Así que la explicación alternativa está viva y es muy plausible: quien escribe por WhatsApp ya venía decidido, y quien navega en Marketplace está comparando precios.

Si eso es lo que pasa, el canal no causa nada: refleja la intención que el cliente ya traía. Y mover a la gente de un sitio a otro no movería el resultado ni un punto.

Controlar por segmento, que es lo que hicimos, descarta ese confusor. No descarta la intención de compra, que no está en los datos 🤷

Por eso el siguiente paso del informe no es "implementar", es probar. Y con los números del capítulo 12 ya sabemos cuánta gente hace falta.

Practica 💪

1. Diseña la prueba que hace falta

Calcula cuántos clientes de Marketplace necesitas en una prueba real para detectar la mitad del efecto observado.

base = mp['compro'].mean()

for objetivo in [base + 0.19, base + 0.10, base + 0.05]:
    h = 2 * (np.arcsin(np.sqrt(objetivo)) - np.arcsin(np.sqrt(base)))
    n = 2 * (1.96 + 0.84) ** 2 / h ** 2
    print('de %.2f%% a %.2f%% (+%.0f puntos) -> %.0f clientes por grupo'
          % (100 * base, 100 * objetivo, 100 * (objetivo - base), n))
de 46.13% a 65.13% (+19 puntos) -> 106 clientes por grupo
de 46.13% a 56.13% (+10 puntos) -> 390 clientes por grupo
de 46.13% a 51.13% (+5 puntos) -> 1566 clientes por grupo

Con 106 por grupo detectas el efecto completo; con 390, la mitad 📋

Ese es el número que va en el informe. Y fíjate en lo útil que es: si en tu Marketplace pasan 200 clientes al mes, la prueba de 390 por grupo tarda cuatro meses. Se puede planificar.

Y el escenario de 5 puntos, con 1.566 por grupo, dice algo también: si la mejora real fuera pequeña, no la vas a poder demostrar con un piloto razonable. Más vale saberlo antes de prometerlo 🗓️

2. ¿Y si el efecto fuera la satisfacción?

Comprueba si los clientes de WhatsApp vienen con más satisfacción de partida, que sería una explicación alternativa.

sa = wa['satisfaccion'].dropna()
sb = mp['satisfaccion'].dropna()

print('satisfaccion media: WhatsApp %.4f (n=%d) | Marketplace %.4f (n=%d)'
      % (sa.mean(), len(sa), sb.mean(), len(sb)))
print('p = %.4f' % stats.ttest_ind(sa, sb, equal_var=False).pvalue)
print('d = %.4f' % d_de_cohen(sa, sb))
satisfaccion media: WhatsApp 3.0429 (n=652) | Marketplace 2.9677 (n=711)
p = 0.3251
d = 0.0534

No hay diferencia: p = 0,3251 y d = 0,0534 🙂

O sea que la satisfacción no explica el efecto del canal. Los clientes de los dos canales llegan igual de contentos.

Este es un ejercicio de descarte, y son los que más valen. No añade nada positivo al informe, pero quita del medio una objeción que alguien iba a hacer en la reunión 🛡️

Ojo con el matiz: descartar la satisfacción no descarta la intención de compra, que es otra cosa y no está medida. Descartar confusores es infinito; descartas los que puedes y dices cuáles quedan 📝

3. ¿Se mantiene a lo largo del tiempo?

Un efecto que solo existe en unos meses es sospechoso. Comprueba trimestre a trimestre.

v['trimestre'] = v['fecha'].dt.to_period('Q')

for tri, g in v.groupby('trimestre'):
    a = g.loc[g['canal'] == 'WhatsApp', 'compro']
    b = g.loc[g['canal'] == 'Marketplace', 'compro']
    if len(a) < 30 or len(b) < 30:
        continue
    print('%s  WhatsApp %.4f (n=%3d) | Marketplace %.4f (n=%3d) | dif %+.4f'
          % (tri, a.mean(), len(a), b.mean(), len(b), a.mean() - b.mean()))
2025Q1  WhatsApp 0.6607 (n=112) | Marketplace 0.5000 (n=140) | dif +0.1607
2025Q2  WhatsApp 0.7109 (n=128) | Marketplace 0.4148 (n=135) | dif +0.2961
2025Q3  WhatsApp 0.5702 (n=114) | Marketplace 0.4874 (n=119) | dif +0.0828
2025Q4  WhatsApp 0.5950 (n=121) | Marketplace 0.5000 (n=138) | dif +0.0950
2026Q1  WhatsApp 0.7661 (n=124) | Marketplace 0.4348 (n=115) | dif +0.3313
2026Q2  WhatsApp 0.6167 (n=120) | Marketplace 0.4224 (n=116) | dif +0.1943

Los seis trimestres, todos positivos, de +8,28 a +33,13 puntos 💪

Esto es lo que más confianza da de todo el análisis. Seis mediciones independientes en el tiempo y las seis apuntan al mismo lado.

Ahora mira cuánto bailan: 16,07, luego 29,61, luego 8,28, 9,50, 33,13 y 19,43. Con unas 120 ventas por celda, cada trimestre trae un margen de error enorme, así que ese baile es exactamente lo que espera el capítulo 9 🎢

Y por eso hay que resistirse a contar historias con la serie. "El efecto se disparó en 2026Q1" o "cayó en 2025Q3" serían justo el p-hacking del capítulo 14: seis mediciones ruidosas siempre tienen una punta y un valle.

4. La versión honesta contando clientes

Repite el análisis contando clientes en vez de ventas, para respetar la independencia.

por_cliente = (v[v['canal'].isin(['WhatsApp', 'Marketplace'])]
               .groupby(['cliente_id', 'canal'])['compro'].mean().reset_index())

ca = por_cliente.loc[por_cliente['canal'] == 'WhatsApp', 'compro']
cb = por_cliente.loc[por_cliente['canal'] == 'Marketplace', 'compro']
dc = ca.mean() - cb.mean()
ec = np.sqrt(ca.var(ddof=1) / len(ca) + cb.var(ddof=1) / len(cb))

print('por venta:   dif %.4f | IC [%.4f, %.4f] | n = %d y %d'
      % (dif, dif - 1.96 * ee, dif + 1.96 * ee, len(wa), len(mp)))
print('por cliente: dif %.4f | IC [%.4f, %.4f] | n = %d y %d'
      % (dc, dc - 1.96 * ec, dc + 1.96 * ec, len(ca), len(cb)))
por venta:   dif 0.1937 | IC [0.1441, 0.2434] | n = 719 y 763
por cliente: dif 0.2040 | IC [0.1486, 0.2593] | n = 428 y 443

Casi el mismo resultado: 19,37 puntos por venta y 20,40 por cliente 🎯

El intervalo se ensancha un poco (de 9,9 a 11,1 puntos de ancho), que es lo esperable al pasar de 719 a 428 casos. Pero la conclusión no se mueve, e incluso sale algo más grande contando clientes.

Esta comprobación es la que convierte la limitación "las filas no son independientes" en una nota al pie en vez de un problema. Cuesta cinco líneas y te la va a preguntar cualquiera que sepa 🧾

5. Lo que pasaría si te equivocaras

Calcula el coste de la decisión en el peor caso: que el efecto sea cero y muevas a todos igual.

clientes_marketplace = mp['cliente_id'].nunique()
coste_por_cliente = 15  # supuesto: tiempo del equipo comercial, en soles

coste = clientes_marketplace * coste_por_cliente

print('clientes de Marketplace: %d' % clientes_marketplace)
print('coste si el efecto fuera cero: S/ %.0f' % coste)
print('valor si el efecto es el pesimista: S/ %.0f'
      % ((dif - 1.96 * ee) * len(mp) * ticket))
print()
print('el efecto tendria que ser mayor que %.4f puntos para pagar el coste'
      % (coste / (len(mp) * ticket)))
clientes de Marketplace: 443
coste si el efecto fuera cero: S/ 6645
valor si el efecto es el pesimista: S/ 62748

el efecto tendria que ser mayor que 0.0153 puntos para pagar el coste

El punto de equilibrio está en 1,53 puntos de mejora, y el peor caso del intervalo es 14,41 puntos 🎉

O sea que la decisión es cómoda: incluso equivocándonos en nueve de cada diez partes del efecto, sigue saliendo a cuenta.

Ese coste de 15 soles por cliente me lo inventé yo y hay que decirlo así de claro en el informe. Lo importante no es el número sino la estructura: cuando pones el punto de equilibrio al lado del intervalo, la decisión se toma sola ⚖️

6. El análisis entero, en una función

Empaqueta todo para poder repetirlo con cualquier par de canales.

def compara_canales(df, canal_a, canal_b, control='segmento'):
    a = df[df['canal'] == canal_a]['compro']
    b = df[df['canal'] == canal_b]['compro']
    d = a.mean() - b.mean()
    e = np.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b))

    aguanta = 0
    grupos = df[control].nunique()
    for g in df[control].unique():
        sub = df[df[control] == g]
        x = sub[sub['canal'] == canal_a]['compro']
        y = sub[sub['canal'] == canal_b]['compro']
        ee_g = np.sqrt(x.var(ddof=1) / len(x) + y.var(ddof=1) / len(y))
        if (x.mean() - y.mean()) - 1.96 * ee_g > 0:
            aguanta += 1

    return ('%-12s vs %-12s dif %+.4f IC [%+.4f, %+.4f] | aguanta en %d/%d %ss'
            % (canal_a, canal_b, d, d - 1.96 * e, d + 1.96 * e,
               aguanta, grupos, control))


for a, b in [('WhatsApp', 'Marketplace'), ('WhatsApp', 'Tienda'),
             ('Tienda', 'Web'), ('Web', 'Marketplace')]:
    print(compara_canales(v, a, b))
WhatsApp     vs Marketplace  dif +0.1937 IC [+0.1441, +0.2434] | aguanta en 4/4 segmentos
WhatsApp     vs Tienda       dif +0.0352 IC [-0.0143, +0.0848] | aguanta en 2/4 segmentos
Tienda       vs Web          dif +0.0390 IC [-0.0103, +0.0883] | aguanta en 1/4 segmentos
Web          vs Marketplace  dif +0.1195 IC [+0.0701, +0.1688] | aguanta en 4/4 segmentos

Las cuatro comparaciones en cuatro líneas 🙌

Y sale una segunda historia que no habíamos visto: Web contra Marketplace son casi 12 puntos, y aguanta en los 4 segmentos. Otro candidato, y bueno.

Mientras que WhatsApp contra Tienda y Tienda contra Web son ruido: sus intervalos contienen el cero, aunque algún segmento suelto salga positivo. Eso de "aguanta en 2 de 4" es justo lo que produce el azar cuando no hay nada.

O sea que el archivo no dice "hay cuatro canales de calidad distinta": dice que Marketplace está solo abajo y los otros tres son equivalentes. Y esa frase es mucho más accionable que un ranking 📊

Comprueba que lo tienes

WhatsApp cierra 19,37 puntos más que Marketplace y aguanta en los cuatro segmentos. ¿Qué recomiendas?

  • Probar con un piloto, porque esto es observacional y no un experimento
  • Mover a todos los clientes de Marketplace a WhatsApp
  • Cerrar Marketplace
  • Nada, porque el efecto es chico (d = 0,40)

Lo que te llevas

  • 📝 Pregunta y criterio antes de mirar. Escribir "me lo tomo en serio a partir de 5 puntos" es lo que impide el p-hacking.
  • 📊 WhatsApp cierra 19,37 puntos más que Marketplace, IC de 14,41 a 24,34, y el monto por venta no cambia entre canales.
  • 🧩 El efecto aguanta en los cuatro segmentos, de +15,28 a +27,61. No es Simpson.
  • 💰 Traducido: entre 62.748 y 105.951 soles en 18 meses, con el ticket mediano y no con la media.
  • 🗣️ Es observacional. Controlar por segmento descarta ese confusor, no la intención de compra. Por eso el siguiente paso es probar, no implementar.
  • ⚖️ Con el punto de equilibrio al lado del intervalo (1,53 puntos contra 14,41 del peor caso), la decisión se toma sola.

Qué viene ahora

Se acabó 🎉 En el capítulo 16 te dejo las referencias: qué leer después, qué herramientas usar y de dónde salió cada cosa de este libro.

¿Tienes alguna duda o consulta?