Hola! Ahora hablemos de velocidad
Hasta aquí todo lo que hicimos funcionaba. Con tres mil filas todo funciona 🙂
El día que tengas un millón, la diferencia entre saber esto y no saberlo es que tu cálculo tarde un segundo o tarde un minuto. Y ese día llega.
NumPy es la librería que está debajo de pandas. Aunque no la uses directo, entenderla te explica por qué pandas se escribe como se escribe 💜
Un arreglo no es una lista
import numpy as np montos = np.array([480.37, 154.83, 2480.84, 524.90]) print(montos) print(type(montos)) print(montos.dtype) print(montos.shape)
[ 480.37 154.83 2480.84 524.9 ] <class 'numpy.ndarray'> float64 (4,)
Tres diferencias con una lista, y las tres importan:
- 🔢 Un solo tipo. Ese
float64vale para todos los elementos. Una lista puede mezclar textos, números y lo que sea; un arreglo no. - 📦 Guardado junto en memoria. Por eso es rápido.
- 📐 Tiene forma. Ese
(4,)dice que es de una dimensión con cuatro elementos.
Y mira lo que pasa si mezclas:
mezcla = np.array([1, 2.5, 'tres']) print(mezcla.dtype) print(mezcla)
<U32 ['1' '2.5' 'tres']
Convirtió todo a texto para que quepa en un solo tipo, y ahora tus números no son números. Eso mismo le pasa a una columna de pandas cuando un archivo trae un "n/a" en medio de los montos, y es la razón del capítulo 2 😅
Vectorizar: la operación se aplica a todo
montos = np.array([480.37, 154.83, 2480.84, 524.90]) print(montos * 1.18) print(montos - 100) print(np.round(montos * 1.18, 2))
[ 566.8366 182.6994 2927.3912 619.382 ] [ 380.37 54.83 2380.84 424.9 ] [ 566.84 182.7 2927.39 619.38]
Sin bucle. Sin for. La multiplicación se aplicó a los cuatro
números de una.
Compáralo con lo que pasa con una lista normal:
lista = [480.37, 154.83, 2480.84, 524.90] print(lista * 2)
[480.37, 154.83, 2480.84, 524.9, 480.37, 154.83, 2480.84, 524.9]
La lista se repitió, igual que el texto del capítulo 2. Con el arreglo se multiplica; con la lista se repite. Son dos mundos distintos y hay que saber en cuál estás 🌸
Y ahora midámoslo, que es lo honesto
No te voy a decir "es más rápido" y ya. Vamos a contarlo:
import time
n = 1_000_000
lista = list(range(n))
arreglo = np.arange(n)
inicio = time.perf_counter()
resultado_bucle = [x * 1.18 for x in lista]
tiempo_bucle = time.perf_counter() - inicio
inicio = time.perf_counter()
resultado_numpy = arreglo * 1.18
tiempo_numpy = time.perf_counter() - inicio
print(f'con bucle : {tiempo_bucle:.3f} s')
print(f'con numpy : {tiempo_numpy:.3f} s')
print(f'numpy es {tiempo_bucle / tiempo_numpy:.0f} veces mas rapido')
con bucle : ...
El número exacto va a cambiar en tu máquina, y el orden de magnitud no: NumPy sale decenas de veces más rápido en la misma operación.
La razón es que el bucle de Python revisa el tipo de cada elemento en cada
vuelta, un millón de veces. NumPy sabe de antemano que todos son
float64 y le manda el trabajo entero a código en C, que no pregunta
nada 🚀
De ahí sale la regla de oro de todo este libro:
si estás escribiendo un for sobre un arreglo o sobre una
columna de pandas, casi seguro hay una forma vectorizada. Búscala.
Filtrar con una condición
Esto es lo que después vas a hacer en pandas todo el día, así que vale la pena verlo aquí primero.
montos = np.array([480.37, 154.83, 2480.84, 524.90, 95.5]) grandes = montos > 500 print(grandes) print(montos[grandes]) print(montos[montos > 500])
[False False True True False] [2480.84 524.9 ] [2480.84 524.9 ]
Mira el paso intermedio, que es la clave: montos > 500 no
devuelve los montos, devuelve una máscara de verdaderos y falsos.
Y meter esa máscara entre corchetes deja pasar solo los True.
Cuando entiendas eso, pandas deja de parecer magia 🌟
Para combinar condiciones hay un detalle que confunde a todo el mundo:
print(montos[(montos > 200) & (montos < 1000)]) print(montos[(montos < 100) | (montos > 2000)])
[480.37 524.9 ] [2480.84 95.5 ]
Se usa & y |, no and ni
or. Y los paréntesis alrededor de cada condición son
obligatorios. Si te los saltas:
montos[montos > 200 & montos < 1000]
TypeError: ufunc 'bitwise_and' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting rule ''safe''
Mensaje feo, causa simple: & se evalúa antes que
>, así que sin paréntesis está intentando hacer
200 & montos. Ponle paréntesis a cada condición y listo.
Los resúmenes
montos = np.array([480.37, 154.83, 2480.84, 524.90, 95.5])
print('suma ', montos.sum())
print('promedio', round(montos.mean(), 2))
print('mediana ', np.median(montos))
print('desv ', round(montos.std(), 2))
print('minimo ', montos.min(), 'maximo', montos.max())
print('posicion del maximo:', montos.argmax())
suma 3736.44 promedio 747.29 mediana 480.37 desv 883.37 minimo 95.5 maximo 2480.84 posicion del maximo: 2
Fíjate en el promedio contra la mediana: 747 contra 480. Un solo monto de 2.480 arrastró el promedio hacia arriba. Es lo mismo que vimos en el capítulo 7 y lo desarrollo entero en la guía de estadística.
Y argmax te da la posición del máximo, no el valor. Eso
es lo que usas cuando quieres saber qué fila fue la más alta 🔍
Los huecos: NaN
En datos reales faltan valores. NumPy los representa con np.nan,
que significa "no es un número".
con_huecos = np.array([480.37, np.nan, 2480.84, np.nan, 95.5]) print(con_huecos.mean()) print(np.isnan(con_huecos)) print(np.isnan(con_huecos).sum(), 'huecos')
nan [False True False True False] 2 huecos
El promedio salió nan 😬 Un solo hueco contamina toda la
operación, y eso es a propósito: NumPy prefiere avisarte antes que darte un
número que ignora datos en silencio.
Hay versiones que sí lo ignoran, y las usas cuando esa es tu decisión consciente:
print(round(np.nanmean(con_huecos), 2)) print(np.nansum(con_huecos)) print(round(con_huecos[~np.isnan(con_huecos)].mean(), 2))
1018.9 3056.71 1018.9
Ese ~ es "lo contrario de", así que
~np.isnan(x) son los que no son nulos. Se usa
muchísimo.
Y una rareza que te va a morder alguna vez:
print(np.nan == np.nan)
False
Un nulo no es igual ni a sí mismo. Por eso nunca se compara con
==: se usa np.isnan() 🌸
Dos dimensiones
tabla = np.array([
[480.37, 10],
[154.83, 13],
[2480.84, 25],
])
print(tabla.shape)
print(tabla[0]) # primera fila
print(tabla[:, 0]) # primera columna
print(tabla[1, 1]) # fila 1, columna 1
print(tabla.sum(axis=0)) # suma por columna
print(tabla.sum(axis=1)) # suma por fila
(3, 2) [480.37 10. ] [ 480.37 154.83 2480.84] 13.0 [3116.04 48. ] [ 490.37 167.83 2505.84]
Ese axis es el que todo el mundo confunde, incluida yo durante
mucho tiempo. La forma que a mí me funciona para recordarlo:
axis=0 aplasta las filas, así que te queda un resultado por
columna. Y axis=1 al revés.
Un caso de verdad
import csv
ARCHIVO = 'ventas-miss-yera.csv'
montos = []
with open(ARCHIVO, encoding='utf-8') as f:
for fila in csv.DictReader(f):
try:
montos.append(float(fila['monto']))
except ValueError:
montos.append(np.nan)
montos = np.array(montos)
print('ventas ', len(montos))
print('con problema ', int(np.isnan(montos).sum()))
print('total S/', round(np.nansum(montos), 2))
print('promedio S/', round(np.nanmean(montos), 2))
print('mediana S/', round(float(np.nanmedian(montos)), 2))
print('sobre 1000 ', int((montos > 1000).sum()))
ventas 3037 con problema 612 total S/ 1959990.12 promedio S/ 808.24 mediana S/ 536.47 sobre 1000 655
Para. Seiscientos doce montos con problema 😳
Ese contador es la línea más importante del bloque, y por eso lo puse. Si no estuviera, habría reportado un total de S/1.959.990 con toda tranquilidad y me habría faltado la quinta parte del archivo.
¿Qué tienen esos 612? Vamos a mirarlos, que es lo que siempre hay que hacer:
problemas = []
with open(ARCHIVO, encoding='utf-8') as f:
for fila in csv.DictReader(f):
try:
float(fila['monto'])
except ValueError:
problemas.append(fila['monto'])
print(problemas[:5])
['921,04', '1071,63', '259,03', '154,08', '209,83']
No era basura: son montos con coma decimal, como en el capítulo 2. Alguien exportó ese archivo desde un Excel configurado en español y ahí la coma es el separador decimal 🙃
Con una línea más, el archivo entero entra:
montos = []
with open(ARCHIVO, encoding='utf-8') as f:
for fila in csv.DictReader(f):
try:
montos.append(float(fila['monto'].replace(',', '.')))
except ValueError:
montos.append(np.nan)
montos = np.array(montos)
print('con problema ', int(np.isnan(montos).sum()))
print('total S/', round(np.nansum(montos), 2))
print('promedio S/', round(np.nanmean(montos), 2))
print('mediana S/', round(float(np.nanmedian(montos)), 2))
con problema 0 total S/ 2434651.67 promedio S/ 801.66 mediana S/ 532.8
Casi medio millón de soles de diferencia en el total, entre el primer intento
y este. Y lo único que cambió fue un .replace(',', '.') 💜
Esa es la lección de verdad del capítulo, más que NumPy: cuenta siempre lo que descartaste, y ve a mirarlo antes de reportar nada.
Y de paso, el promedio contra la mediana otra vez: 801 contra 532. Si le dices a comercial "el ticket promedio es S/801" les estás dando un número que no representa a la mayoría de sus ventas. La mediana es la que hay que reportar, y el porqué está en la guía de estadística.
Ejercicios
1. Tu primer arreglo
Crea un arreglo con cinco montos, aplícale el IGV y redondea.
import numpy as np m = np.array([100.0, 250.5, 480.37, 95.5, 1200.0]) print(np.round(m * 1.18, 2))
[ 118. 295.59 566.84 112.69 1416. ]
2. El arreglo que se volvió texto
Crea un arreglo mezclando números y un texto, y mira qué pasó con el tipo.
unidades = np.array([10, 20, 'n/a', 40]) # una celda vacia en el Excel print(unidades.dtype) print(unidades)
<U21 ['10' '20' 'n/a' '40']
Todo se volvió texto. Si después intentas a.sum() te sale un
error, y ese es exactamente el problema que te encuentras cuando un CSV trae un
"n/a" en una columna de números.
3. Filtrar por rango
De un arreglo de montos, saca solo los que están entre 200 y 1000.
m = np.array([95.5, 480.37, 154.83, 2480.84, 524.90]) print(m[(m >= 200) & (m <= 1000)])
[480.37 524.9 ]
4. El error de los paréntesis
Provoca a propósito el error de combinar condiciones sin paréntesis.
m[m > 200 & m < 1000]
TypeError: ufunc 'bitwise_and' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting rule ''safe''
5. Contar sin bucle
Cuenta cuántos montos superan los S/500, usando la máscara.
print(int((m > 500).sum()))
2
Es el mismo truco de sumar booleanos del capítulo 2, ahora sobre un arreglo entero y sin recorrer nada.
6. Los huecos
Con un arreglo que tenga dos nulos, calcula el promedio de dos formas: la que se contamina y la que no.
# Cinco encuestas de satisfaccion, dos sin contestar puntajes = np.array([100.0, np.nan, 300.0, np.nan, 500.0]) print(puntajes.mean()) print(np.nanmean(puntajes)) print(int(np.isnan(puntajes).sum()), 'sin contestar de', len(puntajes))
nan 300.0 2 sin contestar de 5
Reportar el promedio sin decir cuántos huecos había es esconder información. Esas tres líneas van siempre juntas.
7. La posición del máximo
Encuentra el monto más alto y en qué posición está.
m = np.array([95.5, 480.37, 2480.84, 524.90])
i = m.argmax()
print(f'posicion {i}, valor {m[i]}')
posicion 2, valor 2480.84
8. Sumar por columna
Con una tabla de dos columnas (monto y unidades), saca el total de cada una.
t = np.array([[480.37, 10], [154.83, 13], [2480.84, 25]]) print(t.sum(axis=0))
[3116.04 48. ]
axis=0 aplasta las filas y deja un número por columna. Si te sale
al revés de lo que esperabas, prueba el otro y ya.
9. El nulo que no es igual a sí mismo
Comprueba que np.nan == np.nan es falso y
cuenta los nulos de la forma correcta.
descuentos = np.array([1.0, np.nan, 3.0]) print(np.nan == np.nan) print((descuentos == np.nan).sum()) print(np.isnan(descuentos).sum())
False 0 1
La línea del medio dice cero nulos y hay uno. Si algún día un conteo de nulos
te da cero sospechosamente, mira si estabas comparando con ==.
10. Normalizar
Convierte un arreglo de montos a una escala de 0 a 1, donde 0 es el mínimo y 1 el máximo.
m = np.array([95.5, 480.37, 2480.84, 524.90]) normal = (m - m.min()) / (m.max() - m.min()) print(np.round(normal, 3))
[0. 0.161 1. 0.18 ]
Esa fórmula la vas a volver a ver en la guía de machine learning: es lo que hace el escalado antes de entrenar un modelo.
Lo que te llevas
- 🔢 Un arreglo tiene un solo tipo, y por eso es rápido.
- ⚡ Vectorizar es aplicar la operación a todo de golpe, sin
for. - 🎭 Una condición sobre un arreglo devuelve una máscara de True y False.
- 🔗 Para combinar condiciones:
&y|, con paréntesis en cada una. - 🕳️ Un solo
nancontamina la operación: usanp.nanmeany cuenta los huecos. - 📐
axis=0aplasta filas y deja un resultado por columna.
Y con esto ya tienes todo lo que hace falta para el capítulo 11, que es pandas y es, sin exagerar, el capítulo por el que la mayoría de la gente aprende Python.
Que tengas lindo día! 🌸