Capítulo 11 de 17 15 secciones 21 min

Compartir

NumPy y la vectorización

Por qué el mismo cálculo puede tardar decenas de veces más escrito de una forma que de otra, y el arreglo que está debajo de pandas.

NumPy guarda los números en un arreglo compacto de un solo tipo y aplica las operaciones a todo el arreglo de golpe, sin bucle de Python. Eso se llama vectorizar, y en este capítulo lo medimos en vez de creérnoslo: sobre un millón de números sale decenas de veces más rápido que el bucle. Es además la base sobre la que está construido pandas.

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 se mide en cuántas veces más tarda tu cálculo. Y lo vamos a medir aquí mismo, no a creérnoslo. 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 💜

Antes de arrancar, una pregunta: ¿cuál es el archivo más grande que has abierto en Excel sin que se te cuelgue? Guarda ese número en la cabeza, porque de eso va exactamente este capítulo 🚀

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 float64 vale 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 3 😅

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 3. 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 🚀

Barras horizontales en escala logarítmica con el tiempo de multiplicar una columna por 1,18 sobre 300.000 filas: iterrows 7.040 ms, apply con axis igual a 1 1.198 ms, una lista de Python 20,6 ms y la versión vectorizada 0,33 ms.
La escala es logarítmica, así que cada marca del eje es multiplicar por diez: la diferencia real es mucho mayor de lo que parece. Y mira la tercera barra, que es la que enseña algo: una lista de Python normal le gana por goleada a iterrows. Lo lento no es el lenguaje.

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 8 y lo desarrollo entero en el libro 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 3. 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 el libro de estadística.

np.where: el if que se aplica a todo de golpe

Ya sabes filtrar. Ahora la otra mitad: decidir un valor distinto según la condición, sin bucle 🔀

muestra = np.array([480.37, 154.83, 2480.84, 524.90, 890.00])

etiqueta = np.where(muestra > 1000, 'grande', 'normal')
print(etiqueta)

comision = np.where(muestra > 1000, muestra * 0.05, muestra * 0.02)
print(comision.round(2))
['normal' 'normal' 'grande' 'normal' 'normal']
[  9.61   3.1  124.04  10.5   17.8 ]

Se lee igual que un if de una línea: si la condición, esto; si no, lo otro. La diferencia es que lo hace para los cinco montos a la vez.

Y como el resultado es otro arreglo, se pueden anidar para tres tramos:

tres = np.where(muestra > 1000, 'grande',
                np.where(muestra > 500, 'media', 'chica'))
print(tres)
['chica' 'chica' 'grande' 'media' 'media']

Con tres tramos todavía se lee. Con cinco ya no, y ahí lo que quieres es pd.cut, que llega en el capítulo de pandas 🐼

Ojo a una cosa que despista: np.where calcula las dos ramas enteras y luego elige. Ese muestra * 0.05 se calculó para los cinco montos, aunque solo uno pase de mil. Con datos normales da igual; con una división de por medio, no, porque la rama que no usas puede dividir entre cero y avisarte igual.

La rebanada que no es una copia

Este es el que quiero que no te pase, porque no da error y te cambia los datos por detrás 😖

primeros = muestra[:3]

print('antes  :', muestra)
primeros[0] = 0
print('despues:', muestra)
print('comparten memoria:', np.shares_memory(muestra, primeros))
antes  : [ 480.37  154.83 2480.84  524.9   890.  ]
despues: [   0.    154.83 2480.84  524.9   890.  ]
comparten memoria: True

Toqué primeros y cambió muestra. Yo no toqué muestra 🫠

Y aquí está la diferencia con las listas, que es justo al revés de lo que esperarías después del capítulo de listas. Una rebanada de lista (lista[:3]) te da una copia. Una rebanada de arreglo de numpy no: te da una ventana a los mismos datos, lo que se llama una vista.

No es un fallo, es la razón de que numpy sea rápido: no anda copiando millones de números cada vez que miras un trozo. Pero tienes que saberlo.

copia = muestra[:3].copy()
copia[1] = 999

print('la copia :', copia)
print('el origen:', muestra[:3])
la copia : [   0.    999.   2480.84]
el origen: [   0.    154.83 2480.84]

Un .copy() y ya. La regla que uso: si voy a escribir en la rebanada, .copy(). Si solo voy a leer, no, que para leer la vista es mejor y más rápida 🌸

Y si alguna vez tienes la duda, np.shares_memory(a, b) te lo dice sin adivinar.

El entero que se da la vuelta

Un arreglo tiene un tipo fijo, y ese tipo tiene un techo. Si te pasas del techo, no hay error: el número se da la vuelta 🔄

unidades_ok = np.array([100, 120, 90], dtype=np.int8)

print('tipo         :', unidades_ok.dtype)
print('el maximo es :', np.iinfo(np.int8).max)
print('sumadas      :', unidades_ok.sum())
print('una a una    :', 100 + 120 + 90)

doblado = unidades_ok * 2
print('multiplicadas:', doblado, 'tipo', doblado.dtype)
tipo         : int8
el maximo es : 127
sumadas      : 310
una a una    : 310
multiplicadas: [-56 -16 -76] tipo int8

Mira bien las dos últimas líneas, porque son distintas y las dos son correctas 👀

La suma sale bien. sum() se guarda el resultado en un tipo más grande, así que 310 cabe.

La multiplicación sale mal. unidades_ok * 2 mantiene el int8, y 200 no cabe en un tipo que llega a 127. Así que da la vuelta y sale negativo. Sin aviso, sin error, con los tres valores equivocados.

¿Cuándo te va a pasar esto de verdad? Cuando alguien optimiza la memoria de una tabla enorme poniendo int8 o int16 a columnas que parecían pequeñas, y meses después alguien multiplica esa columna. El int64 por defecto llega a nueve trillones y por eso nunca lo ves 🛡️

Broadcasting, o por qué a veces sí y a veces no

matriz = np.array([[480.37, 154.83],
                   [2480.84, 524.90],
                   [890.00, 320.10]])
print('forma:', matriz.shape)
print((matriz * 1.18).round(2))
forma: (3, 2)
[[ 566.84  182.7 ]
 [2927.39  619.38]
 [1050.2   377.72]]

Un número suelto se aplica a las seis casillas. Eso ya lo viste. Lo que no sabías es que también funciona con un arreglo, si las formas encajan:

descuentos = np.array([0.10, 0.05])
print('un descuento por columna:', descuentos.shape)
print((matriz * (1 - descuentos)).round(2))
un descuento por columna: (2,)
[[ 432.33  147.09]
 [2232.76  498.66]
 [ 801.    304.1 ]]

La tabla es (3, 2) y los descuentos son (2,). numpy estira los dos descuentos hacia abajo, para las tres filas, y aplica el primero a la primera columna y el segundo a la segunda 📐

Ahora con tres, que es lo que harías si pensaras "un descuento por fila":

try:
    matriz * np.array([0.1, 0.2, 0.3])
except ValueError as e:
    print('y con tres:', e)
y con tres: operands could not be broadcast together with shapes (3,2) (3,) 

Falla, y el mensaje te da las dos formas. numpy compara de derecha a izquierda: la última dimensión de la tabla es 2 y la del arreglo es 3, y ni coinciden ni una de las dos es 1. No hay manera de encajarlas.

Para hacer un descuento por fila hay que decírselo con una columna de verdad: np.array([0.1, 0.2, 0.3]).reshape(3, 1). Ahí la forma es (3, 1), ese 1 se estira a las dos columnas, y sale lo que querías 🎯

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 3, 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 el libro de machine learning: es lo que hace el escalado antes de entrenar un modelo.

11. Comisión por tramos, sin un solo if

Aplica 5% a las ventas de más de mil, 3% a las de más de quinientos y 2% al resto, sobre los tres mil montos del archivo.

limpios = montos[~np.isnan(montos)]

comisiones = np.where(limpios > 1000, limpios * 0.05,
                      np.where(limpios > 500, limpios * 0.03,
                               limpios * 0.02))

print('ventas con monto  :', len(limpios))
print('comision total S/ :', round(comisiones.sum(), 2))
print('la mayor       S/ :', round(comisiones.max(), 2))
print('sobre el total    :',
      round(comisiones.sum() / limpios.sum() * 100, 2), '%')
ventas con monto  : 3037
comision total S/ : 99629.08
la mayor       S/ : 211.84
sobre el total    : 4.09 %

Ese ~np.isnan(montos) es "los que no son nulos": la virgulilla da la vuelta a la máscara entera. Se lee raro las primeras veces y luego ya no vives sin ella 🌊

Y fíjate en el último número: la comisión efectiva sale en 4,09%, o sea más cerca del tramo del 5% que del 2%. Y eso que la mayoría de las ventas están en el tramo de abajo 🧠

Pasa porque las ventas grandes, aunque sean pocas, aportan mucho monto, y el porcentaje se calcula sobre soles y no sobre ventas. Es el mismo despiste del promedio y la mediana del capítulo anterior, con otro disfraz.

12. La vista que te cambia el original

Corta un trozo del arreglo grande, tócalo, y mira qué pasó con el total.

copia_segura = montos.copy()
primeros_diez = copia_segura[:10]

antes = np.nansum(copia_segura)
primeros_diez[:] = 0
despues = np.nansum(copia_segura)

print('total antes  : S/', round(antes, 2))
print('total despues: S/', round(despues, 2))
print('se perdieron : S/', round(antes - despues, 2))
print('y yo solo toque primeros_diez:',
      np.shares_memory(copia_segura, primeros_diez))
total antes  : S/ 2434651.67
total despues: S/ 2424934.29
se perdieron : S/ 9717.38
y yo solo toque primeros_diez: True

Miles de soles evaporados del total por escribir en una variable que parecía independiente 💸

Trabajé sobre montos.copy() a propósito, para no romper el arreglo bueno que usan los ejercicios siguientes. Que es exactamente la costumbre que quiero que te lleves.

13. El tipo pequeño que se da la vuelta

Guarda unas unidades en int16, multiplícalas, y busca el punto donde deja de caber.

pedido = np.array([1000, 5000, 20000], dtype=np.int16)

print('el techo del int16:', np.iinfo(np.int16).max)
print('guardado          :', pedido)
print('por dos           :', pedido * 2)
print('en int32          :', pedido.astype(np.int32) * 2)
el techo del int16: 32767
guardado          : [ 1000  5000 20000]
por dos           : [  2000  10000 -25536]
en int32          : [ 2000 10000 40000]

El primero cabe, el segundo cabe, el tercero no. Y no te lo dice: te lo convierte en un número negativo y sigue como si nada 😶

La costumbre que evita esto: si vas a hacer cuentas con una columna, pásala a int32 o float64 antes. Los tipos pequeños son para guardar, no para calcular.

14. Estirar bien y estirar mal

Aplica un descuento por columna y otro por fila a la misma tabla.

tabla = np.array([[480.37, 154.83],
                  [2480.84, 524.90],
                  [890.00, 320.10]])

por_columna = np.array([0.10, 0.05])
por_fila = np.array([0.10, 0.20, 0.05]).reshape(3, 1)

print('la tabla es      ', tabla.shape)
print('por columna es   ', por_columna.shape)
print('por fila es      ', por_fila.shape)
print()
print('descuento por columna:')
print((tabla * (1 - por_columna)).round(2))
print('descuento por fila:')
print((tabla * (1 - por_fila)).round(2))
la tabla es       (3, 2)
por columna es    (2,)
por fila es       (3, 1)

descuento por columna:
[[ 432.33  147.09]
 [2232.76  498.66]
 [ 801.    304.1 ]]
descuento por fila:
[[ 432.33  139.35]
 [1984.67  419.92]
 [ 845.5   304.1 ]]

La diferencia entera está en ese .reshape(3, 1) 📐

Un arreglo de tres se estira hacia los lados por defecto, y ahí choca contra las dos columnas. Convertido en (3, 1), o sea en una columna de tres filas, numpy sabe que va hacia el lado y lo estira bien.

La forma rápida de comprobarlo antes de calcular nada: imprime los .shape. Cuando algo de numpy no cuadra, el 90% de las veces está en las formas y se ve en tres líneas 🔍

El trozo que no es una copia

Y ahora la diferencia entre NumPy y las listas que más caro se paga. Si vienes de listas, tu intuición te va a llevar justo al lado equivocado 🪞

La trampa

Sacas un trozo del arreglo para trabajar con él sin tocar el original, como harías con una lista. Modificas el trozo.

orig = np.array([1, 2, 3, 4, 5])
trozo = orig[1:4]

trozo[0] = 999

print(orig)
# [  1 999   3   4   5]
Qué está mal

El original cambió 🪞 Cortar una lista de Python devuelve una copia, pero cortar un arreglo de NumPy devuelve una vista: la misma memoria, mirada por una ventana. Tocar la vista toca el original.

No es un defecto, es la razón de que NumPy sea rápido: no copia millones de números cada vez que filtras. Pero si vienes de las listas, la intuición te traiciona en la dirección peor, porque no falla, no avisa y corrompe el dato de partida.

Cuando de verdad quieres una copia, se pide: orig[1:4].copy(). Y para saber en qué caso estás, trozo.base te dice si tu arreglo es una vista de otro o vive por su cuenta.

Comprueba que lo tienes

Tienes que multiplicar por 1,18 un millón de números. ¿Qué escribes?

  • La operación sobre el array entero, sin bucle
  • Un for que recorra el millón
  • Un for, pero con range para que sea más rápido
  • Una lista por comprensión

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 nan contamina la operación: usa np.nanmean y cuenta los huecos.
  • 📐 axis=0 aplasta filas y deja un resultado por columna.

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

En NumPy un trozo no es una copia. Es la misma memoria mirada por una ventana.

Y con esto ya tienes todo lo que hace falta para el capítulo 12, 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! 🌸

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?