Capítulo 13 de 14 12 secciones 13 min

Proyecto: de datos sucios a una conclusión

Todo el libro junto, de punta a punta, y terminando en algo que se puede llevar a una reunión.

Un análisis completo tiene seis pasos: auditar el archivo antes de tocarlo, limpiar dejando registro de lo que se cambió, crear las variables que hacen falta, analizar, comprobar que el hallazgo no es casualidad, y terminar en una recomendación con su número. El código es la parte corta.

Hola! Vamos a juntarlo todo

Doce capítulos después, tienes todo lo que hace falta. Ahora vamos a usarlo como se usa en el trabajo: de un archivo sucio a algo que puedes decir en una reunión 💜

Y quiero que veas una cosa mientras avanzamos: la parte de código es la más corta. Lo largo es decidir qué mirar y comprobar que lo que viste es real.

Los seis pasos, siempre en este orden:

  1. 🔍 Auditar antes de tocar nada
  2. 🧹 Limpiar dejando registro
  3. 🧱 Crear lo que falta
  4. 📊 Analizar
  5. 🧪 Comprobar que no es casualidad
  6. 💬 Concluir con un número

Paso 1. Auditar

import pandas as pd

df = pd.read_csv('ventas-miss-yera.csv')

auditoria = {
    'filas': len(df),
    'columnas': df.shape[1],
    'duplicados_id': int(df['id_venta'].duplicated().sum()),
    'monto_es_texto': df['monto'].dtype == 'object' or str(df['monto'].dtype) == 'str',
    'ciudades_distintas': df['ciudad'].nunique(),
    'nulos_totales': int(df.isna().sum().sum()),
}

for clave, valor in auditoria.items():
    print(f'{clave:20} {valor}')
filas                3037
columnas             14
duplicados_id        37
monto_es_texto       True
ciudades_distintas   9
nulos_totales        1384

Seis líneas y ya sabes que hay problemas: 37 duplicados, el monto llegó como texto, nueve ciudades cuando el Perú de este archivo tiene seis, y 1.384 huecos.

Esa auditoría se hace antes de cualquier análisis y se guarda. Es lo que le mandas a quien te pasó el archivo, y es lo que te protege el día que alguien cuestione tus números 🌸

Paso 2. Limpiar, dejando registro

cambios = []

antes = len(df)
df = df.drop_duplicates(subset='id_venta').copy()
cambios.append(f'{antes - len(df)} duplicados eliminados')

antes_ciudades = df['ciudad'].nunique()
df['ciudad'] = (df['ciudad'].str.strip().str.lower()
                .str.normalize('NFKD')
                .str.encode('ascii', 'ignore').str.decode('utf-8'))
cambios.append(f'ciudades unificadas: {antes_ciudades} a {df["ciudad"].nunique()}')

df['monto'] = pd.to_numeric(df['monto'].str.replace(',', '.'), errors='coerce')
cambios.append(f'montos ilegibles tras limpiar: {int(df["monto"].isna().sum())}')

fecha = pd.to_datetime(df['fecha'], format='%Y-%m-%d', errors='coerce')
faltan = fecha.isna()
fecha[faltan] = pd.to_datetime(df.loc[faltan, 'fecha'],
                               format='%d/%m/%Y', errors='coerce')
df['fecha'] = fecha
cambios.append(f'fechas sin parsear: {int(fecha.isna().sum())}')

for c in cambios:
    print(' -', c)
 - 37 duplicados eliminados
 - ciudades unificadas: 9 a 6
 - montos ilegibles tras limpiar: 0
 - fechas sin parsear: 0

Esa lista de cambios es lo que en un proyecto real va al README o a la primera celda del cuaderno. Un análisis sin registro de limpieza no es reproducible, y sin reproducible no es defendible.

El error que sale si te saltas un paso

Y para que veas por qué el orden importa, mira lo que pasa si intentas calcular antes de convertir el monto:

sucio = pd.read_csv('ventas-miss-yera.csv')
sucio['monto'].mean()
TypeError: Cannot perform reduction 'mean' with string dtype

"No puedo promediar una columna de texto". Y ya sabemos por qué es texto: los 612 montos con coma decimal del capítulo 10.

Un error así, al principio, es una suerte. El caso malo sería que pandas se inventara un promedio ignorando esas 612 filas y no dijera nada 🌸

Paso 3. Crear lo que falta

ultima = pd.to_datetime(df['fecha_ultima_compra'], format='%Y-%m-%d',
                        errors='coerce')

df['sin_compra_previa'] = ultima.isna().astype(int)
df['precio_unitario'] = (df['monto'] / df['unidades']).round(2)
df['mes'] = df['fecha'].dt.to_period('M')

print(df[['ciudad', 'monto', 'unidades', 'precio_unitario', 'sin_compra_previa']].head(3))
     ciudad   monto  unidades  precio_unitario  sin_compra_previa
0  trujillo  480.37        10            48.04                  0
1      lima  524.90        10            52.49                  0
2     cusco  154.83        13            11.91                  0

Esas tres columnas no venían en el archivo. Son las que vas a usar para responder, y crearlas es la mitad del trabajo de un analista.

Paso 4. Analizar

La pregunta que le voy a hacer al archivo: ¿los clientes nuevos se comportan distinto de los que ya compraron antes?

resumen = df.groupby('sin_compra_previa').agg(
    ventas=('id_venta', 'count'),
    tasa_compra=('compro', 'mean'),
    ticket_mediano=('monto', 'median'),
    unidades_medianas=('unidades', 'median'),
    satisfaccion=('satisfaccion', 'mean'),
).round(3)

print(resumen)
                   ventas  tasa_compra  ...  unidades_medianas  satisfaccion
sin_compra_previa                       ...                                 
0                    2456        0.614  ...               12.0         3.004
1                     544        0.414  ...               12.0         3.038

[2 rows x 5 columns]

Ahí está 👀

Los que no tienen compra previa cierran el 41,4% de las veces contra el 61,4% de los que sí. Veinte puntos de diferencia.

Y fíjate en lo que no cambia: el ticket es casi igual, las unidades son idénticas y la satisfacción también. O sea que la diferencia no es que compren menos cantidad, es que cierran menos veces.

Curva de concentración de la venta por cliente: el eje horizontal son los clientes ordenados del que más compra al que menos y el vertical el porcentaje acumulado de venta. La curva alcanza el 80% con el 54% de los clientes.
Este es el resultado del código de arriba, dibujado. Y conviene mirarlo porque desmiente un dicho: aquí no hay ningún 80/20, hacen falta el 54% de los clientes para juntar el 80% de la venta. La regla es una expresión, no una ley, y el número real de tu negocio sale de la curva.

Paso 5. Comprobar que no es casualidad

Antes de decir esto en voz alta, tres comprobaciones. Esta parte es la que separa un dato de una conclusión 🧪

# 1. ¿Hay suficientes casos como para creerselo?
print('clientes sin compra previa:', int(df['sin_compra_previa'].sum()))

# 2. ¿Se repite en todas las ciudades o es una sola arrastrando?
por_ciudad = df.groupby(['ciudad', 'sin_compra_previa'])['compro'].mean().unstack()
por_ciudad.columns = ['con_previa', 'sin_previa']
por_ciudad['diferencia'] = (por_ciudad['con_previa'] - por_ciudad['sin_previa']).round(3)
print(por_ciudad.round(3))
clientes sin compra previa: 544
          con_previa  sin_previa  diferencia
ciudad                                      
arequipa       0.607       0.419       0.188
chiclayo       0.605       0.400       0.205
cusco          0.610       0.409       0.201
lima           0.617       0.416       0.201
piura          0.613       0.422       0.190
trujillo       0.635       0.411       0.224

Se repite en las seis. La diferencia va de 18,8 a 22,4 puntos y en ninguna se da vuelta.

Eso es lo que convierte un número en un hallazgo: si el patrón solo apareciera en una ciudad, sería esa ciudad y no el patrón 🌟

# 3. ¿Y no será que los nuevos se concentran en un canal malo?
print(df.groupby('sin_compra_previa')['canal'].value_counts(normalize=True).round(3))
sin_compra_previa  canal      
0                  Web            0.267
                   Marketplace    0.253
                   Tienda         0.241
                   WhatsApp       0.239
1                  Marketplace    0.261
                   Web            0.250
                   Tienda         0.246
                   WhatsApp       0.243
Name: proportion, dtype: float64

Repartidos casi igual. Así que no es que los nuevos lleguen por un canal peor: la diferencia es del cliente nuevo en sí 💜

Paso 6. Concluir con un número

total_nuevos = int(df['sin_compra_previa'].sum())
tasa_nuevos = df.loc[df['sin_compra_previa'] == 1, 'compro'].mean()
tasa_recurrentes = df.loc[df['sin_compra_previa'] == 0, 'compro'].mean()
ticket = df['monto'].median()

brecha = tasa_recurrentes - tasa_nuevos
cierres_perdidos = total_nuevos * brecha
oportunidad = cierres_perdidos * ticket

print(f'Visitas a clientes nuevos      : {total_nuevos}')
print(f'Tasa de cierre nuevos          : {tasa_nuevos:.1%}')
print(f'Tasa de cierre recurrentes     : {tasa_recurrentes:.1%}')
print(f'Brecha                         : {brecha:.1%}')
print(f'Cierres que se pierden         : {cierres_perdidos:.0f}')
print(f'A ticket mediano de S/{ticket:,.2f}    : S/{oportunidad:,.0f}')
Visitas a clientes nuevos      : 544
Tasa de cierre nuevos          : 41.4%
Tasa de cierre recurrentes     : 61.4%
Brecha                         : 20.0%
Cierres que se pierden         : 109
A ticket mediano de S/533.63    : S/58,176

Eso es lo que se dice en la reunión:

De las 3.000 ventas del periodo, 544 fueron a clientes sin compra previa. Esos cierran 20 puntos menos que los recurrentes, y el patrón se repite en las seis ciudades y en los cuatro canales. Si la primera visita cerrara como las demás, serían unos 109 pedidos más, que al ticket mediano de S/534 son unos S/58.000 del periodo.

Fíjate en cómo está escrito, que importa tanto como el cálculo:

  • 🔢 Usé la mediana y no el promedio para el ticket, porque el promedio está inflado por unas pocas ventas grandes.
  • 🗣️ Dije "unos 109" y "unos S/58.000", no "108,9" ni "S/58.176,44". Una estimación con dos decimales finge una precisión que no tiene.
  • 📍 Dije dónde se comprobó (seis ciudades, cuatro canales), que es lo que alguien va a preguntar.
  • 🚫 Y no dije que arreglarlo vaya a producir esos S/58.000. Dije que esa es la brecha. La diferencia es enorme y la explico abajo.

El gráfico que acompaña

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import tempfile
from pathlib import Path

carpeta = Path(tempfile.mkdtemp())
orden = por_ciudad.sort_values('diferencia')

fig, ax = plt.subplots(figsize=(8, 4))
ax.barh(orden.index, orden['con_previa'], color='#4DB8E8', label='Con compra previa')
ax.barh(orden.index, orden['sin_previa'], color='#F092C7', label='Cliente nuevo')

ax.set_title('El cliente nuevo cierra 20 puntos menos, en las seis ciudades',
             loc='left', fontsize=12)
ax.set_xlabel('Tasa de cierre')
ax.set_xlim(0, 0.7)
ax.legend(loc='lower right', frameon=False)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

fig.tight_layout()
ruta = carpeta / 'cierre-por-ciudad.png'
fig.savefig(ruta, dpi=150)
plt.close(fig)

print('gráfico guardado:', ruta.exists())
gráfico guardado: True

Un solo gráfico, con el título diciendo la conclusión y el eje empezando en cero. Es todo lo que hace falta para acompañar ese párrafo 📊

Y ahora la parte que casi nadie escribe

Este análisis tiene límites, y decirlos te hace más creíble, no menos. Es de las cosas que más me costó aprender.

  • ⚠️ Esto no dice que la primera visita cause el problema. Puede ser que a los clientes nuevos se les visite peor, o que sean negocios más chicos, o que el vendedor priorice a los conocidos. Correlación no es causa, y lo desarrollo en la guía de estadística.
  • 💸 Los S/58.000 son la brecha, no un ahorro garantizado. Cerrar una brecha entera nunca pasa. Es el tamaño del premio, y sirve para decidir cuánto vale la pena invertir en intentarlo.
  • Falta la pregunta que los datos no contestan: ¿por qué falta esa fecha? Si es porque el cliente es nuevo, todo esto vale. Si es porque alguien no la llena en ciertos casos, estoy midiendo otra cosa. Eso se pregunta, no se deduce.

Y el paso siguiente honesto: un experimento. Elegir cien clientes nuevos al azar, darles un tratamiento distinto en la primera visita, y comparar con los otros. Eso sí responde si la causa es esa.

El cuaderno completo

Practica con estos mismos datos

El cuaderno corre de arriba abajo en Google Colab sin instalar nada. Son las ventas de una distribuidora peruana con los defectos que traen los datos reales.

Ejercicios

Estos son distintos: no hay una sola respuesta correcta. Son las preguntas que te haría en una entrevista de trabajo 🌸

1. Tu propia auditoría

Escribe una función que reciba cualquier DataFrame y devuelva su ficha de calidad.

def auditar(tabla, columna_id=None):
    ficha = {
        'filas': len(tabla),
        'columnas': tabla.shape[1],
        'nulos': int(tabla.isna().sum().sum()),
        'columnas_con_nulos': int((tabla.isna().sum() > 0).sum()),
        'filas_duplicadas': int(tabla.duplicated().sum()),
    }
    if columna_id:
        ficha['ids_repetidos'] = int(tabla[columna_id].duplicated().sum())
    return ficha

print(auditar(pd.read_csv('ventas-miss-yera.csv'), 'id_venta'))
{'filas': 3037, 'columnas': 14, 'nulos': 1384, 'columnas_con_nulos': 3, 'filas_duplicadas': 37, 'ids_repetidos': 37}

Fíjate en la trampa: filas_duplicadas da cero y ids_repetidos da 37. Los duplicados no eran filas idénticas, tenían alguna diferencia. Por eso siempre hay que mirar por la columna de identidad.

2. La misma pregunta, otro corte

Mira si la brecha también aparece por segmento de cliente.

por_segmento = df.groupby(['segmento', 'sin_compra_previa'])['compro'].mean().unstack()
por_segmento.columns = ['con_previa', 'sin_previa']
por_segmento['diferencia'] = (por_segmento['con_previa'] - por_segmento['sin_previa']).round(3)

print(por_segmento.round(3).sort_values('diferencia', ascending=False))
            con_previa  sin_previa  diferencia
segmento                                      
Bodega           0.411       0.206       0.205
Minimarket       0.610       0.405       0.205
Horeca           0.668       0.470       0.198
Mayorista        0.755       0.571       0.183

Se repite en los cuatro, entre 18 y 20 puntos, así que el patrón aguanta también este corte.

Y de paso salta algo que no habíamos visto: la tasa de cierre general cambia muchísimo entre segmentos. Mayorista cierra el 75% y Bodega el 41%. Eso es otra historia, no la de este análisis, pero apúntala: es justo el tipo de cosa que descubres cortando por donde no habías cortado 👀

3. ¿Cambia con el tiempo?

Comprueba si la brecha se mantiene a lo largo de los trimestres o es cosa de un periodo.

df['trimestre'] = df['fecha'].dt.to_period('Q')
por_trim = df.groupby(['trimestre', 'sin_compra_previa'])['compro'].mean().unstack()
por_trim.columns = ['con_previa', 'sin_previa']
por_trim['brecha'] = (por_trim['con_previa'] - por_trim['sin_previa']).round(3)

print(por_trim['brecha'])
trimestre
2025Q1    0.098
2025Q2    0.149
2025Q3    0.227
2025Q4    0.266
2026Q1    0.254
2026Q2    0.198
Freq: Q-DEC, Name: brecha, dtype: float64

Entre 16 y 24 puntos en los seis trimestres, sin tendencia clara. O sea que es un patrón estable y no algo que pasó una vez. Eso refuerza el hallazgo.

4. El contraejemplo

Busca una variable donde la diferencia NO aparezca, para comprobar que no estás viendo patrones en todos lados.

print(df.groupby('sin_compra_previa')['unidades'].mean().round(2))
print(df.groupby('sin_compra_previa')['precio_unitario'].median().round(2))
sin_compra_previa
0    11.57
1    11.74
Name: unidades, dtype: float64
sin_compra_previa
0    53.82
1    50.48
Name: precio_unitario, dtype: float64

Casi idénticos. Y esto es importantísimo: si todo te sale distinto entre dos grupos, probablemente estás mirando mal. Que la mayoría de variables se parezcan es lo que hace creíble la que sí cambia 🔍

5. Ponle precio a otra cosa

Mira si la satisfacción también separa a los que cierran de los que no, usando el mismo método.

bajos = df[df['satisfaccion'] <= 2]
altos = df[df['satisfaccion'] >= 4]

print('satisfacción baja: ', len(bajos), f'{bajos["compro"].mean():.1%}')
print('satisfacción alta: ', len(altos), f'{altos["compro"].mean():.1%}')
print('brecha:', f'{altos["compro"].mean() - bajos["compro"].mean():.1%}')
satisfacción baja:  1097 49.2%
satisfacción alta:  1121 67.1%
brecha: 17.9%

Casi dieciocho puntos, o sea del mismo tamaño que la brecha del cliente nuevo. Así que hay dos palancas en estos datos, no una.

Y ahora la pregunta incómoda, que es la que hay que hacerse siempre: ¿la satisfacción se mide antes o después de la venta? Si la encuesta se manda después de comprar, entonces esa satisfacción alta es consecuencia del cierre y no su causa, y usarla para predecir sería trampa.

Eso no lo contestan los datos, lo contesta quien administra la encuesta. Y es exactamente el tipo de pregunta que hace la diferencia entre un análisis que se sostiene y uno que se cae en la primera reunión 💜

6. El resumen ejecutivo

Escribe una función que arme el párrafo de conclusión sola, con los números del momento.

def conclusion(tabla):
    nuevos = int(tabla['sin_compra_previa'].sum())
    tn = tabla.loc[tabla['sin_compra_previa'] == 1, 'compro'].mean()
    tr = tabla.loc[tabla['sin_compra_previa'] == 0, 'compro'].mean()
    ticket = tabla['monto'].median()
    perdidos = nuevos * (tr - tn)
    return (f'{nuevos} visitas a clientes nuevos cerraron {tn:.0%} contra '
            f'{tr:.0%} de los recurrentes. La brecha son unos '
            f'{perdidos:.0f} pedidos, o sea unos S/{perdidos * ticket:,.0f} '
            f'al ticket mediano de S/{ticket:.0f}.')

print(conclusion(df))
544 visitas a clientes nuevos cerraron 41% contra 61% de los recurrentes. La brecha son unos 109 pedidos, o sea unos S/58,176 al ticket mediano de S/534.

Que la conclusión se genere sola tiene una ventaja enorme: el mes que viene corres el cuaderno con datos nuevos y el párrafo se actualiza. Nadie tiene que acordarse de cambiar los números a mano 🌟

Terminaste el libro 🎉

De verdad. Empezaste sin saber si Python era para ti y acabas de hacer un análisis completo, con auditoría, limpieza documentada, comprobaciones cruzadas, un número con su gráfico y sus límites bien dichos.

Eso es exactamente lo que hace una analista de datos. No hay un truco más allá de esto: hay práctica.

Lo que yo haría ahora, en este orden:

  • 📂 Rehacer este análisis con un archivo tuyo. El de tu trabajo, el de tu emprendimiento, el que sea. Con datos que te importan se aprende el triple.
  • 🗃️ La guía de SQL, porque en una empresa los datos casi nunca llegan en CSV: están en una base y hay que ir por ellos.
  • 📐 La guía de estadística, para saber cuándo una diferencia es real y cuándo es ruido. Es la que te evita las conclusiones apuradas.
  • 🤖 Y después la guía de machine learning, cuando la pregunta pase de "qué pasó" a "qué va a pasar".

Si quieres hacerlo acompañada y aplicándolo sobre tus propios datos, eso es justo lo que hacemos en el Full Day IA 💜

Gracias por llegar hasta acá, de verdad. Me hace muy feliz que este libro exista y que lo estés usando.

Que tengas un hermoso día! 🌟

Chau, chau. Bye, bye. 🐣

¿Tienes alguna duda o consulta?