Capítulo 16 de 17 15 secciones 20 min

Compartir

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 lo hago siempre en seis pasos: auditar el archivo antes de tocar nada, limpiar dejando registro de lo que cambié, crear las variables que faltan, analizar, comprobar que el hallazgo no es casualidad y terminar en una recomendación con su número al lado. El código es la parte corta, te lo prometo.

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 11.

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 📊

Paso 7. Comprobar que la conclusión no depende de ti

Este paso no lo hace casi nadie y es el que más tranquila me deja 🧘‍♀️

En el paso 2 tomé decisiones: quité duplicados por id_venta, normalicé ciudades, convertí montos con coma. Cada una de esas decisiones la tomé yo. La pregunta honesta es: ¿la conclusión aguanta si las hubiera tomado distinto?

Se responde recalculando lo mismo sobre varios recortes:

def brecha_de(tabla):
    nuevos = tabla.loc[tabla['sin_compra_previa'] == 1, 'compro'].mean()
    recurrentes = tabla.loc[tabla['sin_compra_previa'] == 0, 'compro'].mean()
    return len(tabla), nuevos, recurrentes, recurrentes - nuevos


crudo = pd.read_csv('ventas-miss-yera.csv')
crudo['sin_compra_previa'] = crudo['fecha_ultima_compra'].isna().astype(int)

recortes = {
    'todo, como lo limpie': df,
    'sin quitar duplicados': crudo,
    'sin las satisfacciones nulas': df.dropna(subset=['satisfaccion']),
    'solo 2025': df[df['fecha'].dt.year == 2025],
    'solo 2026': df[df['fecha'].dt.year == 2026],
    'sin Lima': df[df['ciudad'] != 'lima'],
}

for nombre, tabla in recortes.items():
    filas, n, r, b = brecha_de(tabla)
    print(f'{nombre:<30} {filas:>5} filas   '
          f'nuevos {n:.1%}   recurrentes {r:.1%}   brecha {b:.1%}')
todo, como lo limpie            3000 filas   nuevos 41.4%   recurrentes 61.4%   brecha 20.0%
sin quitar duplicados           3037 filas   nuevos 41.7%   recurrentes 61.2%   brecha 19.6%
sin las satisfacciones nulas    2769 filas   nuevos 41.2%   recurrentes 61.0%   brecha 19.9%
solo 2025                       2008 filas   nuevos 42.7%   recurrentes 61.5%   brecha 18.7%
solo 2026                        992 filas   nuevos 38.7%   recurrentes 61.3%   brecha 22.6%
sin Lima                        2529 filas   nuevos 41.3%   recurrentes 61.3%   brecha 20.0%

Ahí está la respuesta y me gusta mucho 💜

La brecha se mueve entre el 18,7% y el 22,6% en los seis recortes. Ni uno solo la hace desaparecer, ni le cambia el signo, ni la duplica. Incluso sin limpiar nada, con los duplicados dentro, sale 19,6%.

Eso quiere decir que el hallazgo no lo fabriqué yo con la limpieza. Estaba en los datos antes de que yo los tocara, y eso es lo que te permite defenderlo en una reunión cuando alguien pregunte "¿y si hubieras quitado los duplicados de otra forma?" 🛡️

La otra lectura, la que también hay que decir: el recorte de 2026 da 22,6% y el de 2025 da 18,7%. La brecha se está ensanchando, no cerrando. Ese es un dato adicional que salió gratis de esta comprobación.

Y si algún recorte hubiera dado una brecha cercana a cero, la conclusión no sería "el análisis está mal". Sería "la conclusión vale para unos casos y no para otros", y eso también se reporta. Un hallazgo que solo aparece con una limpieza concreta es un hallazgo de la limpieza, no de los datos.

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 el libro 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 🌟

7. Tu propio análisis de sensibilidad

Recalcula la brecha cortando por canal y por segmento, y di si hay algún grupo donde no se cumple.

print('por canal:')
for canal in sorted(df['canal'].unique()):
    filas, n, r, b = brecha_de(df[df['canal'] == canal])
    print(f'  {canal:<13} {filas:>5} filas   brecha {b:.1%}')

print('por segmento:')
for segmento in sorted(df['segmento'].unique()):
    filas, n, r, b = brecha_de(df[df['segmento'] == segmento])
    print(f'  {segmento:<13} {filas:>5} filas   brecha {b:.1%}')
por canal:
  Marketplace     763 filas   brecha 14.3%
  Tienda          726 filas   brecha 19.3%
  Web             792 filas   brecha 22.2%
  WhatsApp        719 filas   brecha 24.6%
por segmento:
  Bodega          707 filas   brecha 20.5%
  Horeca          742 filas   brecha 19.8%
  Mayorista       750 filas   brecha 18.3%
  Minimarket      801 filas   brecha 20.5%

Ocho recortes más y la brecha sigue en pie en los ocho 💪

Cuando una diferencia aparece en cada corte que se te ocurre, ya no estás mirando una casualidad de un subgrupo: estás mirando algo que atraviesa el negocio entero. Y eso cambia lo que puedes afirmar en la reunión.

Ahora mira los dos bloques por separado, porque no dicen lo mismo 👀

Por segmento no pasa nada: los cuatro están entre 18,3% y 20,5%. La brecha es igual de grande vendas a una bodega o a un mayorista.

Por canal sí pasa algo: Marketplace 14,3% y WhatsApp 24,6%. Diez puntos de diferencia entre el canal donde menos duele y el canal donde más. Eso no invalida nada de lo anterior, pero es una pregunta nueva que no tenías, y salió sola de comprobar 🎣

Sin más datos no sé por qué. Puede que en Marketplace el cliente llegue más decidido, o que ahí no haya visita de vendedor que valga. Lo que sí sé es a quién preguntárselo.

8. Dejarlo guardado para el yo del mes que viene

Escribe el resultado en un CSV y, al lado, un archivo de texto que diga cómo se hizo.

import importlib.metadata as metadatos
import os
import sys
import tempfile

carpeta_salida = tempfile.mkdtemp()

resumen_final = pd.DataFrame([
    {'grupo': 'nuevos', 'visitas': int((df['sin_compra_previa'] == 1).sum()),
     'cierre': round(tasa_nuevos, 4)},
    {'grupo': 'recurrentes', 'visitas': int((df['sin_compra_previa'] == 0).sum()),
     'cierre': round(tasa_recurrentes, 4)},
])

ruta_csv = os.path.join(carpeta_salida, 'cierre-por-grupo.csv')
resumen_final.to_csv(ruta_csv, index=False, encoding='utf-8-sig')

ruta_txt = os.path.join(carpeta_salida, 'como-se-hizo.txt')
with open(ruta_txt, 'w', encoding='utf-8') as f:
    f.write('Fuente: ventas-miss-yera.csv\n')
    f.write(f'Python {sys.version_info.major}.{sys.version_info.minor}\n')
    for paquete in ('pandas', 'numpy', 'matplotlib'):
        f.write(f'{paquete}=={metadatos.version(paquete)}\n')
    f.write('Limpieza: duplicados por id_venta, ciudad normalizada,\n')
    f.write('monto con coma decimal convertido, fecha en dos formatos.\n')

print('archivos escritos:', sorted(os.listdir(carpeta_salida)))
print()
print(open(ruta_csv, encoding='utf-8-sig').read().rstrip())
print()
print(open(ruta_txt, encoding='utf-8').read().rstrip())
archivos escritos: ['cierre-por-grupo.csv', 'como-se-hizo.txt']

grupo,visitas,cierre
nuevos,544,0.4136
recurrentes,2456,0.614

Fuente: ventas-miss-yera.csv
Python 3.11
pandas==3.0.5
numpy==2.4.6
matplotlib==3.11.1
Limpieza: duplicados por id_venta, ciudad normalizada,
monto con coma decimal convertido, fecha en dos formatos.

Ese segundo archivo es el que marca la diferencia entre un análisis y un análisis que alguien puede repetir 📝

Tres meses después nadie se acuerda de si quitó los duplicados por id_venta o por la fila entera, ni con qué versión de pandas salieron esos números. Y esa es exactamente la pregunta que te van a hacer cuando el resultado del mes que viene no cuadre con este.

El utf-8-sig del CSV es para que Excel abra los acentos bien con doble clic, que es como lo va a abrir la persona a la que se lo mandes 🪟

Y ahí tienes el capítulo de módulos y el de archivos trabajando juntos: importlib.metadata para las versiones, csv con codificación declarada para la salida. Todo el libro cabe en esta celda 💜

El análisis que nadie puede repetir, ni tú

Y para terminar el libro, la trampa que separa un análisis de una anécdota. Estas cuatro líneas corren perfectas y aun así no valen para entregar 🔁

La trampa

Terminas el análisis, sale una conclusión clara y la mandas. El código está ordenado y cada paso hace lo suyo.

d = pd.read_csv('ventas-miss-yera.csv')
muestra = d.sample(500)

resultado = analizar(muestra)
print('conclusion:', resultado)
Qué está mal

Corre perfecto, y aun así nadie puede repetir esto, ni siquiera tú dentro de un mes 🔁

sample(500) sin random_state coge 500 filas distintas cada vez que se ejecuta. Tu conclusión sale de una muestra que ya no existe y que no puedes recuperar, así que no hay manera de comprobarla.

Y tiene una hermana que da menos miedo del que merece: poner la ruta completa de la carpeta de tu computadora en vez del nombre del archivo. Esa al menos avisa, porque en cualquier otra máquina el programa se cae en la primera línea. La de la muestra no avisa nunca.

Si alguien corre tu código y le sale otra cosa, no hay forma de saber quién tiene razón. Ruta relativa, semilla fija, y el número de filas impreso al lado del resultado. Son tres líneas y son la diferencia entre un análisis y una anécdota.

Comprueba que lo tienes

Te dan un CSV nuevo y quieres sacar conclusiones. ¿Qué haces primero?

  • Mirar cuántas filas hay, qué falta y qué está sucio
  • Hacer los gráficos, que es lo que van a mirar
  • Calcular los promedios de cada columna
  • Preguntar qué quieren que salga

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.
  • 🗃️ El libro de SQL, porque en una empresa los datos casi nunca llegan en CSV: están en una base y hay que ir por ellos.
  • 📐 El libro 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 el libro 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 💜

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Un análisis que no se puede repetir no es un análisis. Es una anécdota.

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. 🐣

Ya programas. ¿Y ahora qué construyes?

Python era el medio, no el objetivo. Con lo que sabes ahora puedes automatizar cosas de tu trabajo esta misma semana, y te recomiendo que empieces por ahí antes que por nada más 💪

Cuando quieras que eso deje de ser un script que corres tú y pase a ser algo que opera un proceso solo, con sus pruebas y su forma de avisarte cuando se rompe, ese salto es el curso de ingeniería de IA en producción.

La diferencia entre las dos cosas es más grande de lo que parece desde acá: un script lo arreglas tú cuando falla porque estabas mirando, y un sistema tiene que avisarte solo. Eso son cuatro semanas de práctica, no un capítulo más.

Y si sientes que necesitas repasar esto mismo con alguien explicándotelo, sin saltar todavía a construir sistemas, en las clases grabadas está el curso de Python que dicté en aula, con las preguntas que hicieron los alumnos. Son las mismas cosas que acabas de leer, dichas en voz alta 🐣

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?