Capítulo 11 de 14 11 secciones 11 min

pandas: cargar, limpiar y agrupar

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. Se lee un CSV con read_csv, se filtra con una condición entre corchetes, se limpia con los métodos .str, y se resume con groupby. El aviso SettingWithCopyWarning que sale en rojo significa que estás modificando algo que puede ser una copia, y se evita 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.

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 9 (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 10: 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 10.

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  :

  • '
  • Len(df)) df = df.drop_duplicates(subset='id_venta') print('despues:', len(df))</pre> <pre class="salida">antes : 3037 despues: 3000</pre> <p>Treinta
  • Siete ventas duplicadas
Si sumas montos sin quitarlas, tu total
está inflado y nadie te va a avisar.

Ese subset='id_venta' importa: sin él, pandas solo quita filas idénticas en todas las columnas, y un duplicado real muchas veces tiene alguna diferencia tonta.

# 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 10, 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 10, 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 3, 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 3? 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 🔍

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 la guía 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 3 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 🌟

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.

En el capítulo 12 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! 🌟

¿Tienes alguna duda o consulta?