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:
- 🔍 Auditar antes de tocar nada
- 🧹 Limpiar dejando registro
- 🧱 Crear lo que falta
- 📊 Analizar
- 🧪 Comprobar que no es casualidad
- 💬 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.
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. 🐣