Capítulo 12 de 17 14 secciones 20 min

Compartir

pandas

El capítulo por el que la mayoría aprende Python, sobre un archivo sucio de verdad y con el aviso que sale en rojo.

pandas guarda los datos en un DataFrame, que es una tabla con nombres de columna. Leo el CSV con read_csv, filtro con una condición entre corchetes, limpio con los métodos .str y resumo con groupby. Y ese aviso rojo, el SettingWithCopyWarning, me está diciendo que estoy modificando algo que puede ser una copia. Se quita usando .loc o .copy().

Hola! Llegamos al capítulo por el que casi todos aprendemos Python

Te voy a ser honesta: mucha gente aprende Python solo para llegar aquí 😄

pandas es una tabla con superpoderes. Todo lo de los capítulos anteriores sigue valiendo por debajo, y ahora se escribe muchísimo más corto.

Y aprovecho para preguntarte algo: ¿cuál es la tabla que abres todas las semanas en tu trabajo? Esa es con la que tienes que repetir este capítulo cuando lo termines, porque con datos tuyos se aprende el triple 🐼

Flujo de trabajo con pandas: leer, mirar la forma y los nulos, limpiar, filtrar, agrupar, unir y graficar. Saltarse el paso de mirar los datos lleva a analizar información que nunca revisaste.
Los siete pasos son siempre los mismos y el segundo es el que se salta todo el mundo. Mirar shape, dtypes e isna antes de tocar nada es lo que evita la mitad de los análisis que salen mal.

Cargar el archivo

import pandas as pd

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

print(df.shape)
print(df.columns.tolist())
(3037, 14)
['id_venta', 'fecha', 'cliente_id', 'ciudad', 'segmento', 'canal', 'categoria', 'unidades', 'monto', 'descuento', 'fecha_ultima_compra', 'satisfaccion', 'compro', 'monto_final_facturado']

Una línea. Todo el capítulo 10 (abrir, decodificar, partir por comas, armar diccionarios) resuelto en una línea 🌸

Ese df es la convención universal para "DataFrame". Vas a verlo en todo internet.

Y lo primero que se hace, siempre, en este orden:

print(df.dtypes)
id_venta                   int64
fecha                        str
cliente_id                   str
ciudad                       str
segmento                     str
canal                        str
categoria                    str
unidades                   int64
monto                        str
descuento                float64
fecha_ultima_compra          str
satisfaccion             float64
compro                     int64
monto_final_facturado    float64
dtype: object

Mira monto. Salió como texto, no como número.

Ya sabemos por qué del capítulo 11: son los 612 montos con coma decimal. pandas intentó convertir la columna, encontró comas, y la dejó entera como texto en vez de inventarse nada 👏

Si tu pandas es anterior a la versión 3.0 vas a ver object donde aquí dice str. Es lo mismo.

print(df.head(3))
   id_venta       fecha cliente_id  ... satisfaccion compro monto_final_facturado
0     11369  2025-06-05      C0045  ...          4.0      1                480.37
1     11926  2026-01-05      C0325  ...          3.0      1                440.39
2     11849  2026-04-29      C0260  ...          1.0      1                148.33

[3 rows x 14 columns]

Fíjate en la fila 1: la ciudad dice lima en minúscula. Ya vamos a llegar a eso 👀

Series y DataFrame

Una columna sola es una Series; la tabla entera es un DataFrame. Es la misma diferencia que entre un arreglo de una dimensión y uno de dos en el capítulo 11.

print(type(df['ciudad']))
print(type(df[['ciudad', 'monto']]))
<class 'pandas.Series'>
<class 'pandas.DataFrame'>

Corchetes simples te dan una columna (Series). Corchetes dobles te dan una tabla con esas columnas (DataFrame). Esa confusión es de las más comunes al empezar.

print(df['ciudad'].value_counts())
ciudad
Arequipa    550
Piura       517
Chiclayo    512
Cusco       504
Trujillo    480
lima        123
Líma        118
Lima        118
LIMA        115
Name: count, dtype: int64

Ahí está el desastre 😅 Lima escrita de cuatro formas.

Si agrupas así, Lima aparece como cuatro ciudades chiquitas de 115 a 123 ventas, en vez de una de 474 que sería la segunda del país. Un informe con ese error se presenta y nadie lo nota.

Limpiar, que es el trabajo de verdad

# Duplicados primero
print('antes  :<p></p><ul class="grid grid-cols-1 md:grid-cols-3 gap-3 my-6 list-none pl-0"><li class="bg-white rounded-xl border border-gray-100 shadow-sm px-4 py-3 flex items-start gap-3"><span class="w-1.5 h-1.5 rounded-full bg-pink flex-shrink-0 mt-2"></span><span class="text-gray-600 font-poppins text-sm leading-relaxed">'</span></li><li class="bg-white rounded-xl border border-gray-100 shadow-sm px-4 py-3 flex items-start gap-3"><span class="w-1.5 h-1.5 rounded-full bg-pink flex-shrink-0 mt-2"></span><span class="text-gray-600 font-poppins text-sm leading-relaxed">Len(df))
df = df.drop_duplicates(subset='id_venta')
print('despues:', len(df))</pre>
<pre class="salida">antes  : 3037
despues: 3000</pre>
<p>Treinta</span></li><li class="bg-white rounded-xl border border-gray-100 shadow-sm px-4 py-3 flex items-start gap-3"><span class="w-1.5 h-1.5 rounded-full bg-pink flex-shrink-0 mt-2"></span><span class="text-gray-600 font-poppins text-sm leading-relaxed">Siete ventas duplicadas</span></li></ul><pre>Si sumas montos sin quitarlas, tu total
está inflado y nadie te va a avisar.<p></p>
<p>Ese <code>subset='id_venta'</code> importa: sin él, pandas solo quita filas
idénticas en <em>todas</em> las columnas, y un duplicado real muchas veces tiene
alguna diferencia tonta.</p>
<pre># La ciudad, con los metodos .str
df['ciudad'] = (df['ciudad'].str.strip().str.lower()
                .str.normalize('NFKD')
                .str.encode('ascii', 'ignore').str.decode('utf-8'))

print(df['ciudad'].value_counts())
ciudad
arequipa    546
chiclayo    510
piura       506
cusco       496
trujillo    471
lima        471
Name: count, dtype: int64

De nueve ciudades a seis, y Lima dejó de estar partida en cuatro pedazos 🌟

Ese .str del medio es la clave: aplica un método de texto a toda la columna de golpe. Es la vectorización del capítulo 11, aplicada a cadenas. Sin él tendrías que recorrer las tres mil filas.

Lo del normalize('NFKD') es lo que quita las tildes: separa la letra de su tilde y después bota lo que no es ASCII. Es un truco feo y es el que funciona.

# El monto, con la coma decimal
df['monto'] = pd.to_numeric(df['monto'].str.replace(',', '.'), errors='coerce')

print(df['monto'].dtype)
print('no convertidos:', df['monto'].isna().sum())
print(round(df['monto'].sum(), 2))
float64
no convertidos: 0
2411283.83

errors='coerce' significa "lo que no puedas convertir, déjalo como nulo en vez de reventar". Y esa segunda línea, la que cuenta cuántos no convirtió, es obligatoria: si no da cero, tienes que ir a mirarlos.

Los nulos

print(df.isna().sum().sort_values(ascending=False).head(4))
descuento              595
fecha_ultima_compra    544
satisfaccion           231
cliente_id               0
dtype: int64

Y aquí viene la parte que casi ningún tutorial dice. La pregunta correcta no es "con qué relleno", es "por qué falta".

Mira lo que pasa cuando lo preguntas de verdad:

df['sin_compra_previa'] = df['fecha_ultima_compra'].isna().astype(int)

print(df.groupby('sin_compra_previa')['compro'].mean().round(4))
sin_compra_previa
0    0.6140
1    0.4136
Name: compro, dtype: float64

Los que sí tenían una compra anterior compran el 61%; los que no, el 41% 😮

Veinte puntos de diferencia. Si hubiera rellenado esa fecha con un promedio, habría borrado lo más predictivo de todo el archivo.

Ese hueco no era un dato que falta, era un dato en sí mismo: el cliente es nuevo. Y la forma correcta de tratarlo es marcarlo con una bandera, no taparlo 💜

Barras horizontales con el porcentaje de filas sin dato por columna en el archivo de ventas de la guía: descuento es la que más falta, seguida de la fecha de última compra y de satisfacción.
Esto es lo que devuelve isna sobre el archivo que vas a descargar, dibujado. Y fíjate en cuál falta más: descuento. Ahí el hueco casi nunca significa que se perdió el dato, significa que esa venta no tuvo descuento, y rellenarlo con el promedio sería inventarse una rebaja que nadie hizo.

Seleccionar y filtrar

print(df.loc[0, 'ciudad'])
print(df.loc[0:2, ['ciudad', 'monto']])
trujillo
     ciudad   monto
0  trujillo  480.37
1      lima  524.90
2     cusco  154.83
grandes = df[df['monto'] > 1000]
print(len(grandes))

lima_grandes = df[(df['ciudad'] == 'lima') & (df['monto'] > 1000)]
print(len(lima_grandes))
811
132

Es exactamente la máscara del capítulo 11, con & y con paréntesis en cada condición. Si usas and, pandas te lo dice clarito:

df[(df['ciudad'] == 'lima') and (df['monto'] > 1000)]
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().

Y para filtrar por varios valores, en vez de encadenar |:

print(len(df[df['ciudad'].isin(['lima', 'arequipa'])]))
print(len(df[df['canal'].str.contains('What', na=False)]))
1017
719

El aviso rojo que todo el mundo ve y nadie entiende

Este te va a salir. Vamos a provocarlo para que sepas qué significa.

solo_lima = df[df['ciudad'] == 'lima']
solo_lima['con_igv'] = solo_lima['monto'] * 1.18

print(solo_lima['con_igv'].head(2))
1      619.3820
22    1161.4268
Name: con_igv, dtype: float64

Puede que en tu versión ese bloque te saque un SettingWithCopyWarning en rojo. Funciona igual, y ese es justo el problema: pandas no sabe si solo_lima es una tabla nueva o una vista de la original, así que no puede garantizarte a cuál de las dos le estás escribiendo.

La solución es una palabra:

solo_lima = df[df['ciudad'] == 'lima'].copy()
solo_lima['con_igv'] = solo_lima['monto'] * 1.18

print(len(solo_lima), round(solo_lima['con_igv'].sum(), 2))
471 440587.39

La regla que te ahorra el aviso para siempre: si filtras y después vas a modificar, pon .copy() al filtrar. Es la misma historia del capítulo 4, la copia que no se copió 🌸

groupby: la operación estrella

print(df.groupby('ciudad')['monto'].sum().round(2).sort_values(ascending=False))
ciudad
arequipa    437846.95
chiclayo    415093.21
piura       400833.27
trujillo    396905.88
cusco       387225.38
lima        373379.14
Name: monto, dtype: float64

¿Te acuerdas del bucle con por_ciudad.get(ciudad, 0) + monto del capítulo 4? Esto es lo mismo, en una línea. Por eso te hice escribir el bucle primero 💜

resumen = df.groupby('ciudad').agg(
    ventas=('id_venta', 'count'),
    total=('monto', 'sum'),
    ticket_medio=('monto', 'mean'),
    ticket_mediano=('monto', 'median'),
).round(2).sort_values('total', ascending=False)

print(resumen)
          ventas      total  ticket_medio  ticket_mediano
ciudad                                                   
arequipa     546  437846.95        801.92          550.96
chiclayo     510  415093.21        813.91          534.76
piura        506  400833.27        792.16          535.09
trujillo     471  396905.88        842.69          520.03
cusco        496  387225.38        780.70          521.04
lima         471  373379.14        792.74          524.90

Esa es una tabla que puedes mandar tal cual, y mira lo que cuenta: el ticket medio y el mediano se llevan entre doscientos y trescientos soles de diferencia en todas las ciudades. O sea que en todas hay unas pocas ventas grandes que jalan el promedio hacia arriba.

Y se puede agrupar por dos cosas:

print(df.groupby(['ciudad', 'canal'])['monto'].sum().round(0).head(6))
ciudad    canal      
arequipa  Marketplace    122605.0
          Tienda          98950.0
          Web            115457.0
          WhatsApp       100835.0
chiclayo  Marketplace    105309.0
          Tienda         107364.0
Name: monto, dtype: float64

Y darle la vuelta para que se lea como una tabla de doble entrada, que es lo que en Excel sería una tabla dinámica:

tabla = df.pivot_table(index='ciudad', columns='canal',
                       values='monto', aggfunc='sum').round(0)
print(tabla)
canal     Marketplace    Tienda       Web  WhatsApp
ciudad                                             
arequipa     122605.0   98950.0  115457.0  100835.0
chiclayo     105309.0  107364.0  105317.0   97104.0
cusco        120266.0   83713.0  106840.0   76406.0
lima          93359.0   94818.0   92062.0   93140.0
piura         90695.0  109055.0   98153.0  102931.0
trujillo      87528.0   97619.0  114782.0   96977.0

Juntar dos tablas

metas = pd.DataFrame({
    'ciudad': ['lima', 'arequipa', 'cusco', 'piura', 'chiclayo', 'trujillo'],
    'meta': [400000, 420000, 380000, 400000, 390000, 400000],
})

comparativo = resumen.reset_index().merge(metas, on='ciudad')
comparativo['cumplimiento'] = (comparativo['total'] / comparativo['meta'] * 100).round(1)

print(comparativo[['ciudad', 'total', 'meta', 'cumplimiento']])
     ciudad      total    meta  cumplimiento
0  arequipa  437846.95  420000         104.2
1  chiclayo  415093.21  390000         106.4
2     piura  400833.27  400000         100.2
3  trujillo  396905.88  400000          99.2
4     cusco  387225.38  380000         101.9
5      lima  373379.14  400000          93.3

Un aviso importante con merge: comprueba siempre cuántas filas te quedaron. Si una ciudad estaba escrita distinta en las dos tablas, esa fila desaparece sin decir nada.

print(len(resumen), len(metas), len(comparativo))
6 6 6

Seis, seis y seis. Si el tercero fuera cinco, faltaría una ciudad y habría que ir a buscar por qué antes de seguir 🔍

Encadenar, para no llenar el cuaderno de variables

Fíjate en cómo hemos ido trabajando: parte = ..., resumen = ..., comparativo = .... Funciona, y a las veinte celdas ya no te acuerdas de cuál era cuál 😅

pandas deja escribir todo el camino de una vez, y se lee de arriba abajo como una receta:

resumen_canal = (df
                 .query('monto > 0')
                 .assign(con_igv=lambda t: t['monto'] * 1.18,
                         grande=lambda t: t['monto'] > 1000)
                 .groupby('canal')
                 .agg(ventas=('id_venta', 'count'),
                      total=('con_igv', 'sum'),
                      grandes=('grande', 'sum'))
                 .assign(pct_grandes=lambda t: (t['grandes'] / t['ventas'] * 100).round(1))
                 .sort_values('total', ascending=False))

print(resumen_canal.round(0))
             ventas     total  grandes  pct_grandes
canal                                              
Web             786  751779.0      205         26.0
Marketplace     757  732367.0      216         28.0
Tienda          717  702211.0      210         29.0
WhatsApp        719  669523.0      180         25.0

Eso son cinco pasos y ni una variable intermedia. Las piezas nuevas 🧩

  • 🔎 query('monto > 0') es lo mismo que df[df['monto'] > 0], escrito como se dice. Con dos o tres condiciones se nota mucho: query('monto > 0 and canal == "Web"'), sin paréntesis ni ampersands.
  • assign crea columnas y devuelve una tabla nueva, así que no toca la original. Ese lambda t: es "la tabla tal como está en este punto de la cadena", que es lo que hace que funcione en medio del encadenado.
  • 📊 agg con nombres (ventas=('id_venta', 'count')) te deja bautizar cada columna del resultado. Sin eso salen apiladas y con nombres raros.

Lo que devuelve la tabla, dicho en cristiano: los cuatro canales facturan parecido, y entre el 25% y el 29% de sus ventas pasan de mil soles. Otra vez cuatro canales que se parecen mucho 🤷‍♀️

Y el detalle de formato que hace que esto sea legible: los paréntesis alrededor de todo. Sin ellos, Python no deja partir la línea y te queda una fila kilométrica.

Lo que pesa tu tabla, y cómo bajarlo a la mitad

Esto no importa con tres mil filas. Importa muchísimo con tres millones, que es cuando el cuaderno se pone lento y no sabes por qué 🐢

texto = df['ciudad'].memory_usage(deep=True)
categoria = df['ciudad'].astype('category').memory_usage(deep=True)

print('como texto   :', f'{texto:,} bytes')
print('como category:', f'{categoria:,} bytes')
print('se ahorra    :', round((1 - categoria / texto) * 100, 1), '%')
print('valores distintos:', df['ciudad'].nunique(), 'de', len(df), 'filas')
como texto   : 280,198 bytes
como category: 93,468 bytes
se ahorra    : 66.6 %
valores distintos: 6 de 3000 filas

Ahí está el porqué: seis valores distintos repetidos tres mil veces.

Como texto, pandas guarda las ocho letras de "arequipa" quinientas cuarenta y seis veces. Como category, guarda las seis palabras una vez y luego tres mil numeritos que apuntan a ellas 🏷️

Y ese deep=True no es opcional. Sin él, pandas te cuenta lo que pesan los punteros y no el texto al que apuntan, así que te sale un número bonito y falso.

antes = df.memory_usage(deep=True).sum()

compacto = df.copy()
for columna in ['ciudad', 'segmento', 'canal', 'categoria']:
    compacto[columna] = compacto[columna].astype('category')

despues = compacto.memory_usage(deep=True).sum()

print('antes  :', f'{antes:,} bytes')
print('despues:', f'{despues:,} bytes')
print('ahorro :', round((1 - despues / antes) * 100, 1), '%')
antes  : 1,626,208 bytes
despues: 864,274 bytes
ahorro : 46.9 %

Cuatro columnas convertidas y la tabla pesa la mitad. Sin perder un solo dato 💜

La regla para saber cuándo aplicarlo: si los valores distintos son muchos menos que las filas, va a category. Ciudad, canal, segmento, estado, sí. Nombre de cliente o código de venta, no, porque ahí casi cada fila es distinta y encima le añades el diccionario.

Ejercicios

1. Las tres primeras miradas

Carga el archivo y muestra su forma, sus tipos y cuántos nulos tiene la columna satisfaccion.

import pandas as pd

d = pd.read_csv('ventas-miss-yera.csv')
print(d.shape)
print(d['monto'].dtype)
print(d['satisfaccion'].isna().sum())
(3037, 14)
str
234
2. Cuántas ventas por canal

Cuenta las ventas de cada canal, ordenadas de mayor a menor.

print(d['canal'].value_counts())
canal
Web            801
Marketplace    776
Tienda         735
WhatsApp       725
Name: count, dtype: int64
3. Filtrar con dos condiciones

Cuenta las ventas del canal WhatsApp con más de 10 unidades.

print(len(d[(d['canal'] == 'WhatsApp') & (d['unidades'] > 10)]))
434
4. El error del and

Escribe el filtro anterior con and y lee el error.

d[(d['canal'] == 'WhatsApp') and (d['unidades'] > 10)]
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().

Se lee así: and quiere saber si una Series entera es verdadera o falsa, y una Series de tres mil valores no es ninguna de las dos.

5. Una columna nueva

Crea una columna con el monto por unidad y muestra las tres ventas con el precio unitario más alto.

d = d.drop_duplicates(subset='id_venta').copy()
d['monto'] = pd.to_numeric(d['monto'].str.replace(',', '.'), errors='coerce')
d['precio_unitario'] = (d['monto'] / d['unidades']).round(2)

print(d.nlargest(3, 'precio_unitario')[['ciudad', 'unidades', 'monto', 'precio_unitario']])
        ciudad  unidades    monto  precio_unitario
2398     Piura         1  3586.26          3586.26
1905  Trujillo         1  3503.27          3503.27
1317  Trujillo         1  3018.31          3018.31

nlargest es más corto que ordenar y cortar, y además es más rápido porque no ordena la tabla entera.

6. Resumen por segmento

Saca ventas, total y ticket mediano por segmento, ordenado por total.

print(d.groupby('segmento').agg(
    ventas=('id_venta', 'count'),
    total=('monto', 'sum'),
    mediana=('monto', 'median'),
).round(2).sort_values('total', ascending=False))
            ventas       total  mediana
segmento                               
Mayorista      750  1392252.83  1869.26
Horeca         742   560342.87   743.00
Minimarket     801   332348.67   413.74
Bodega         707   126339.46   183.26
7. El hueco que dice algo

Comprueba si el hueco de descuento también cambia la tasa de compra.

d['sin_descuento'] = d['descuento'].isna().astype(int)
print(d.groupby('sin_descuento')['compro'].mean().round(4))
sin_descuento
0    0.6017
1    0.4807
Name: compro, dtype: float64

Doce puntos. También dice algo, y antes de usarlo hay que preguntarse si ese dato existe en el momento de predecir. Eso se desarrolla en el libro de machine learning.

8. Tabla de doble entrada

Arma una tabla con las unidades vendidas por segmento y categoría.

print(d.pivot_table(index='segmento', columns='categoria',
                    values='unidades', aggfunc='sum'))
categoria   Abarrotes  Bebidas  Cuidado personal  Limpieza  Snacks
segmento                                                          
Bodega           1564     1659              1862      1344    1595
Horeca           1872     1571              1535      1716    1868
Mayorista        1944     1796              1425      1718    1912
Minimarket       1981     1678              2274      1813    1675
9. Provoca el aviso

Filtra sin .copy(), agrega una columna, y después hazlo bien.

parte = d[d['ciudad'] == 'Cusco'].copy()
parte['doble'] = parte['monto'] * 2

print(len(parte), round(parte['doble'].sum(), 2))
496 774450.76

Con .copy() no hay aviso y sabes exactamente sobre qué estás escribiendo. Es una palabra que se pone siempre y se olvida el problema.

10. Del bucle a pandas

Escribe el total por canal de las dos formas: con el bucle del capítulo 4 y con groupby. Comprueba que dan lo mismo.

a_mano = {}
for canal, monto in zip(d['canal'], d['monto']):
    a_mano[canal] = a_mano.get(canal, 0) + monto

con_pandas = d.groupby('canal')['monto'].sum().to_dict()

print(sorted(round(v, 2) for v in a_mano.values()))
print(sorted(round(v, 2) for v in con_pandas.values()))
print(a_mano.keys() == con_pandas.keys())
[567392.49, 591519.04, 619760.73, 632611.57]
[567392.49, 591519.04, 619760.73, 632611.57]
True

Idénticos. La diferencia es que el de pandas es una línea, no se te olvida inicializar nada, y con un millón de filas tarda una fracción 🌟

11. Todo el análisis en una sola cadena

Ticket promedio, número de ventas y porcentaje de ventas grandes, por ciudad, sin variables intermedias.

por_ciudad_cadena = (df
                     .query('monto > 0')
                     .assign(grande=lambda t: t['monto'] > 1000)
                     .groupby('ciudad')
                     .agg(ventas=('id_venta', 'count'),
                          ticket=('monto', 'mean'),
                          grandes=('grande', 'sum'))
                     .assign(pct=lambda t: (t['grandes'] / t['ventas'] * 100).round(1))
                     .sort_values('ticket', ascending=False))

print(por_ciudad_cadena.round(1))
          ventas  ticket  grandes   pct
ciudad                                 
trujillo     465   856.7      135  29.0
chiclayo     507   820.0      133  26.2
arequipa     541   815.0      144  26.6
lima         470   799.5      132  28.1
piura        503   799.2      133  26.4
cusco        493   785.9      134  27.2

Seis ciudades con tickets entre setecientos y ochocientos y pico. Otra vez la misma historia de este archivo: casi todo se parece, y decirlo es el hallazgo 🌸

12. Qué columnas vale la pena convertir

Mira cuántos valores distintos tiene cada columna de texto y cuánto pesa cada una.

for columna in ['ciudad', 'segmento', 'canal', 'categoria', 'cliente_id']:
    distintos = df[columna].nunique()
    pesa = df[columna].memory_usage(deep=True)
    cabe = df[columna].astype('category').memory_usage(deep=True)
    print(f'{columna:<12} {distintos:>5} distintos  '
          f'{pesa:>8,} -> {cabe:>8,}  '
          f'ahorra {(1 - cabe / pesa) * 100:.0f}%')
ciudad           6 distintos   280,198 ->   93,468  ahorra 67%
segmento         4 distintos   284,542 ->   93,347  ahorra 67%
canal            4 distintos   281,965 ->   93,344  ahorra 67%
categoria        5 distintos   288,807 ->   93,419  ahorra 68%
cliente_id     617 distintos   276,088 ->  134,342  ahorra 51%

Mira cliente_id, que es el que enseña la regla 👀

Las cuatro primeras tienen entre cuatro y seis valores distintos y ahorran dos tercios. La quinta tiene seiscientos diecisiete y ahorra la mitad: sigue mereciendo la pena, pero mucho menos.

Y ahí está la regla, dicha con los números delante: cuantos más valores distintos, menos ahorras, porque el diccionario que category guarda aparte va creciendo. Con una columna donde cada fila es distinta, category pesa más que el texto y encima te complica los filtros 🙃

13. Los mismos números y aun así distintos

Compara el conteo de ciudades antes y después de convertir a category.

a = df['ciudad'].value_counts()
b = df['ciudad'].astype('category').value_counts()

print(a)
print(b)
print('mismos numeros:', list(a.values) == list(b.values))
print('mismo orden   :', list(a.index) == list(b.index))
print('equals dice   :', a.equals(b))
ciudad
arequipa    546
chiclayo    510
piura       506
cusco       496
trujillo    471
lima        471
Name: count, dtype: int64
ciudad
arequipa    546
chiclayo    510
piura       506
cusco       496
lima        471
trujillo    471
Name: count, dtype: int64
mismos numeros: True
mismo orden   : False
equals dice   : False

Los mismos seis conteos y equals dice que no 🤨

Hay dos motivos, y los dos son útiles de saber.

El empate. Lima y Trujillo tienen 471 las dos. value_counts ordena por conteo, y cuando hay empate el desempate lo decide el orden interno, que en category es alfabético y en texto es el de aparición. Mismos datos, otro orden.

El tipo del índice. Uno devuelve un Index normal y el otro un CategoricalIndex, y equals compara también eso.

La lección práctica: si vas a comparar dos resultados, ordénalos igual primero. Con a.sort_index() y b.sort_index() los valores coinciden. Comparar sin ordenar es la forma más fácil de creer que rompiste algo cuando no rompiste nada 🧘‍♀️

14. El merge que multiplica las filas

Junta las ventas con una tabla de vendedores por ciudad y mira qué le pasa al total.

vendedores = pd.DataFrame({
    'ciudad': ['lima', 'lima', 'lima', 'arequipa', 'arequipa'],
    'vendedor': ['Ana', 'Beto', 'Caro', 'Dani', 'Eli'],
})

dos_ciudades = df[df['ciudad'].isin(['lima', 'arequipa'])][
    ['id_venta', 'ciudad', 'monto']]

juntas = dos_ciudades.merge(vendedores, on='ciudad')

print('ventas antes     :', len(dos_ciudades))
print('vendedores       :', len(vendedores))
print('filas despues    :', len(juntas))
print('total real       : S/', round(dos_ciudades['monto'].sum(), 2))
print('total tras juntar: S/', round(juntas['monto'].sum(), 2))
print('multiplicado por :',
      round(juntas['monto'].sum() / dos_ciudades['monto'].sum(), 2))
ventas antes     : 1017
vendedores       : 5
filas despues    : 2505
total real       : S/ 811226.09
total tras juntar: S/ 1995831.32
multiplicado por : 2.46

Ahí lo tienes: el arriba te avisaba de las filas que desaparecen en un merge. Este es el caso contrario y da más miedo 😱

Cada venta de Lima se juntó con los tres vendedores de Lima, así que ahora aparece tres veces. Y al sumar, el total se infló casi dos veces y media.

Lo peligroso es que el número resultante es perfectamente creíble. No hay error, no hay nulos, la tabla se ve bien. Simplemente estás facturando el doble de lo que vendiste.

La forma de que pandas te avise sola:

try:
    dos_ciudades.merge(vendedores, on='ciudad', validate='many_to_one')
except Exception as e:
    print(type(e).__name__ + ':', str(e).splitlines()[0])
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge

Ese validate='many_to_one' es tu declaración de lo que esperas: muchas ventas, una fila por ciudad. Si la tabla de la derecha tiene la clave repetida, revienta en vez de multiplicar 🛡️

Las cuatro opciones son one_to_one, one_to_many, many_to_one y many_to_many. Ponlo siempre, aunque estés segura. Sobre todo cuando estés segura.

Corregir un dato y que no se corrija nada

Esta es, con diferencia, la trampa que más veces me han traído en consultoría. Sin error, sin aviso, y con el dato sucio intacto 🫥

La trampa

Filtras las filas de Lima, corriges la ciudad en ese trozo, y sigues. Sin error y sin aviso.

lima = ventas[ventas['ciudad'] == 'Lima']
lima['ciudad'] = 'LIMA'

print((ventas['ciudad'] == 'LIMA').sum())
# 0
Qué está mal

Cero. El original no se enteró de nada 🫥 Ese lima es una copia, así que corregiste la copia y seguiste trabajando con el original sucio.

Y el problema es que a veces sí funciona, según cómo se hizo el filtro. Eso es lo peor de todo: un comportamiento que cambia según el caso no se aprende nunca, solo se sufre. Por eso pandas lleva años avisando de esto y por eso terminó cambiando las reglas para que siempre sea copia.

Se corrige sobre el original diciendo dónde: ventas.loc[ventas['ciudad'] == 'Lima', 'ciudad'] = 'LIMA'. Con .loc y una sola instrucción, no hay copia en medio y no hay duda.

Comprueba que lo tienes

Filtras un DataFrame, le cambias un valor al resultado y pandas te avisa con un warning. ¿Qué te está diciendo?

  • Que no sabe si estás tocando el original o una copia
  • Que el filtro está mal escrito
  • Que la columna no existe
  • Que hay que usar loc siempre

Lo que te llevas

  • 📥 read_csv y después .dtypes, siempre. Una columna de dinero que salió como texto tiene algo dentro.
  • 🧹 Duplicados con subset, texto con .str, números con to_numeric(errors='coerce') y contando cuántos no convirtieron.
  • ❓ Ante un hueco, la pregunta es por qué falta, no con qué se rellena.
  • 🔗 Filtros con & y |, con paréntesis, nunca con and.
  • 📋 Si filtras y vas a modificar, .copy().
  • 📊 groupby con .agg() te da la tabla que se manda tal cual.
  • 🔍 Después de un merge, cuenta las filas.

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

Se corrige sobre el original y diciendo dónde. Todo lo demás es corregir una copia.

En el capítulo 15 hacemos que todo esto se vea, con matplotlib. Y ahí hay una regla que vale más que todas las opciones de color juntas.

Que tengas un hermoso día! 🌟

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?