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 🐼
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 💜
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 quedf[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. - ➕
assigncrea columnas y devuelve una tabla nueva, así que no toca la original. Eselambda t:es "la tabla tal como está en este punto de la cadena", que es lo que hace que funcione en medio del encadenado. - 📊
aggcon 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_csvy después.dtypes, siempre. Una columna de dinero que salió como texto tiene algo dentro. - 🧹 Duplicados con
subset, texto con.str, números conto_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 conand. - 📋 Si filtras y vas a modificar,
.copy(). - 📊
groupbycon.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.