Indice / NumPy / 3.5
Broadcasting
Lo que permite que dos arrays de formas distintas participen de la
misma operación. Ya lo venías usando sin saberlo - a * 2 es un caso
- y en cuanto aparecen dos dimensiones se vuelve la fuente número uno de
ValueError.
Para qué sirve
Centrar, escalar, normalizar: todo lo que consiste en «a cada fila, restarle
lo suyo» se escribe en una línea gracias a broadcasting. Y el error que produce
- operands could not be broadcast together with shapes (10,24) (10,)
- es imposible de arreglar por prueba y error si no se conocen las tres
reglas.
ELI5Explicado facil
Es lo que hacés al repartir la cuenta: si son cinco personas y un solo monto, nadie escribe el monto cinco veces. Se entiende que ese número vale para todos.
Broadcasting es esa misma idea, con una regla precisa para decidir cuándo un valor «vale para todos» y cuándo la cuenta no cierra.
Las tres reglas
- El caso mas simpleUn array y un numero suelto. El escalar no tiene forma, asi que NumPy lo estira a lo que haga falta: es el caso que ya venias usando sin llamarlo broadcasting.
lecturas * 9 / 5 + 32 - La regla, en tres pasosNumPy alinea las dos formas por la DERECHA. Despues, dimension por dimension: si son iguales, pasa; si una vale 1, la estira; si falta, la agrega como 1 y la estira. Cualquier otra cosa es un error.
# alinear a la derecha, comparar de a pares - El mismo array, otra formaLos promedios por sensor tambien son 10 numeros, pero ahora hay que restarlos por FILA. Con shape (10,) no funciona: alineado a la derecha, ese 10 se compara contra las 24 columnas.
lecturas - lecturas.mean(axis=1) - El arreglo: darle la forma que le faltareshape(-1, 1) convierte los 10 promedios en una COLUMNA de shape (10, 1). Ahora el 1 del eje 1 se estira a 24, y cada fila recibe su propio promedio.
lecturas - lecturas.mean(axis=1).reshape(-1, 1) - Lo mismo, sin reshapekeepdims=True le pide a la reduccion que conserve la dimension en vez de eliminarla. Devuelve (10, 1) directamente, y evita el reshape de la linea anterior.
lecturas - lecturas.mean(axis=1, keepdims=True) - Y el caso que sorprendeDos vectores que parecen incompatibles producen una MATRIZ. Cada uno estira su dimension de tamano 1, y el resultado es la combinacion de todos con todos: 10 x 24 numeros a partir de 34.
columna = a.reshape(-1, 1) # (10, 1)\nfila = b.reshape(1, -1) # (1, 24)\ncolumna + fila # (10, 24)
NumPy alinea las dos formas por la derecha y las compara dimensión por dimensión:
| Situación | Qué hace |
|---|---|
| Las dos dimensiones son iguales | Pasa |
| Una de las dos vale 1 | La estira hasta la otra |
| A una le falta esa dimensión | La agrega como 1, y la estira |
| Cualquier otra cosa | ValueError |
import numpy as np
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
print("lecturas:", lecturas.shape)
print()
print("Broadcasting es lo que permite que dos arrays de formas DISTINTAS")
print("participen de una operacion elemento a elemento.")
print()
print("Las reglas, aplicadas alineando las formas por la DERECHA:")
print(" 1. si las dos dimensiones son iguales -> pasa")
print(" 2. si una de las dos vale 1 -> se estira")
print(" 3. si a una le falta la dimension -> se agrega como 1")
print(" cualquier otra cosa -> ValueError")
print()
print("CASO 1 - con un escalar. Es el que ya usabas:")
print(" lecturas * 2 -> shape", (lecturas * 2).shape)
print(" El numero no tiene forma, asi que se repite en todos lados.")
print()
print("CASO 2 - restar el promedio de cada HORA (una columna por hora):")
por_hora = np.nanmean(lecturas, axis=0)
print(" por_hora.shape :", por_hora.shape)
print(" lecturas - por_hora -> shape", (lecturas - por_hora).shape)
print(" Alineado a la derecha: 24 contra 24 coinciden, y el 10 de la")
print(" izquierda no tiene con quien: se repite.")
print(" comprobacion: el promedio por hora del resultado da cero ->",
np.round(np.nanmean(lecturas - por_hora, axis=0), 10)[:4], "...")
print()
print("CASO 3 - restar el promedio de cada SENSOR. Aca NO alcanza:")
por_sensor = np.nanmean(lecturas, axis=1)
print(" por_sensor.shape:", por_sensor.shape)
try:
lecturas - por_sensor
except ValueError as error:
print(" lecturas - por_sensor ->", type(error).__name__)
print(" ", error)
print(" Alineado a la derecha, ese 10 se compara contra las 24")
print(" columnas. No son iguales y ninguno vale 1: choca.")
print()
print("EL ARREGLO - darle la dimension que le falta:")
columna = por_sensor.reshape(-1, 1)
print(" por_sensor.reshape(-1, 1).shape ->", columna.shape)
centrado = lecturas - columna
print(" lecturas - columna -> shape", centrado.shape)
print(" comprobacion: el promedio por sensor del resultado da cero ->",
np.round(np.nanmean(centrado, axis=1), 10))
print()
print("O directamente, pidiendole a la reduccion que conserve la dimension:")
otro = lecturas - np.nanmean(lecturas, axis=1, keepdims=True)
print(" np.nanmean(..., keepdims=True).shape ->",
np.nanmean(lecturas, axis=1, keepdims=True).shape)
print(" ¿mismo resultado?", np.allclose(centrado, otro, equal_nan=True))
print(" keepdims=True es la forma recomendada: dice la intencion y")
print(" evita acordarse de si el reshape va (-1, 1) o (1, -1).")
lecturas: (10, 24)
Broadcasting es lo que permite que dos arrays de formas DISTINTAS
participen de una operacion elemento a elemento.
Las reglas, aplicadas alineando las formas por la DERECHA:
1. si las dos dimensiones son iguales -> pasa
2. si una de las dos vale 1 -> se estira
3. si a una le falta la dimension -> se agrega como 1
cualquier otra cosa -> ValueError
CASO 1 - con un escalar. Es el que ya usabas:
lecturas * 2 -> shape (10, 24)
El numero no tiene forma, asi que se repite en todos lados.
CASO 2 - restar el promedio de cada HORA (una columna por hora):
por_hora.shape : (24,)
lecturas - por_hora -> shape (10, 24)
Alineado a la derecha: 24 contra 24 coinciden, y el 10 de la
izquierda no tiene con quien: se repite.
comprobacion: el promedio por hora del resultado da cero -> [-0. -0. 0. 0.] ...
CASO 3 - restar el promedio de cada SENSOR. Aca NO alcanza:
por_sensor.shape: (10,)
lecturas - por_sensor -> ValueError
operands could not be broadcast together with shapes (10,24) (10,)
Alineado a la derecha, ese 10 se compara contra las 24
columnas. No son iguales y ninguno vale 1: choca.
EL ARREGLO - darle la dimension que le falta:
por_sensor.reshape(-1, 1).shape -> (10, 1)
lecturas - columna -> shape (10, 24)
comprobacion: el promedio por sensor del resultado da cero -> [-0. -0. -0. -0. -0. 0. -0. -0. -0. -0.]
O directamente, pidiendole a la reduccion que conserve la dimension:
np.nanmean(..., keepdims=True).shape -> (10, 1)
¿mismo resultado? True
keepdims=True es la forma recomendada: dice la intencion y
evita acordarse de si el reshape va (-1, 1) o (1, -1).
(10,) falla y (10, 1) funciona
Sobre una grilla (10, 24), un vector de 10 promedios por sensor se
alinea a la derecha: ese 10 queda comparado contra las 24 columnas. No
son iguales y ninguno vale 1, así que choca.
reshape(-1, 1) lo convierte en (10, 1): ahora el 10 se
alinea con el 10 y el 1 se estira a 24. Cada fila recibe su propio promedio.
La forma recomendada es pedírselo a la reducción:
np.nanmean(a, axis=1, keepdims=True) devuelve (10, 1)
directamente, y dice la intención sin obligar a recordar si el reshape va
(-1, 1) o (1, -1).
Un promedio calculado con axis=0 se resta sin nada: su
forma ya coincide con la de las columnas.
Un promedio calculado con axis=1 necesita keepdims=True.
Siempre.
Centrar y escalar
import numpy as np
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
# La lectura imposible se marca como ausente antes de cualquier cuenta:
# con 999.9 adentro, la media y el desvio de S05 no significan nada.
limpias = np.where(lecturas > 50, np.nan, lecturas)
print("NaN antes:", int(np.isnan(lecturas).sum()),
"| despues de marcar el imposible:", int(np.isnan(limpias).sum()))
medias = np.nanmean(limpias, axis=1, keepdims=True)
desvios = np.nanstd(limpias, axis=1, keepdims=True)
print()
print("medias.shape :", medias.shape)
print("desvios.shape:", desvios.shape)
z = (limpias - medias) / desvios
print()
print("z = (limpias - medias) / desvios -> shape", z.shape)
print("Una sola linea, dos broadcasts: (10,24) con (10,1), dos veces.")
print()
print("Y esto es lo que consigue: cada sensor queda con media 0 y desvio 1,")
print("aunque midan temperaturas distintas.")
print(f" {'sensor':<8} {'media orig':>11} {'desvio orig':>12} "
f"{'media z':>9} {'desvio z':>9}")
for i in range(10):
print(f" S{i + 1:02d} {float(medias[i, 0]):>11.2f} "
f"{float(desvios[i, 0]):>12.2f} "
f"{float(np.nanmean(z[i])):>9.2f} {float(np.nanstd(z[i])):>9.2f}")
print()
print("Para que sirve: ahora las lecturas de sensores distintos se pueden")
print("comparar entre si. Un z de 2.1 significa lo mismo en el deposito")
print("que en la sala de servidores: 'dos desvios por encima de LO SUYO'.")
print()
print("El maximo z de cada sensor, que es su hora mas atipica:")
horas = [f"h{h:02d}" for h in range(24)]
for i in range(10):
fila = z[i]
if np.isnan(fila).all():
continue
j = int(np.nanargmax(np.abs(fila)))
print(f" S{i + 1:02d} {horas[j]} z = {fila[j]:+.2f} "
f"({limpias[i, j]:.1f} grados)")
print()
print("Sin centrar, la hora mas calurosa seria siempre la del sensor mas")
print("caluroso. Centrando, cada uno se compara consigo mismo.")
print()
print("El z-score vuelve en la leccion 3.7 como criterio de atipico, y en")
print("el ejercicio EJ-3.2 sobre las notas de los estudiantes.")
NaN antes: 4 | despues de marcar el imposible: 5
medias.shape : (10, 1)
desvios.shape: (10, 1)
z = (limpias - medias) / desvios -> shape (10, 24)
Una sola linea, dos broadcasts: (10,24) con (10,1), dos veces.
Y esto es lo que consigue: cada sensor queda con media 0 y desvio 1,
aunque midan temperaturas distintas.
sensor media orig desvio orig media z desvio z
S01 21.73 1.57 -0.00 1.00
S02 19.62 2.06 -0.00 1.00
S03 18.75 1.70 -0.00 1.00
S04 21.63 1.16 -0.00 1.00
S05 19.81 0.84 -0.00 1.00
S06 21.08 1.64 0.00 1.00
S07 24.55 2.08 -0.00 1.00
S08 20.04 1.69 -0.00 1.00
S09 22.78 1.99 -0.00 1.00
S10 17.53 1.97 -0.00 1.00
Para que sirve: ahora las lecturas de sensores distintos se pueden
comparar entre si. Un z de 2.1 significa lo mismo en el deposito
que en la sala de servidores: 'dos desvios por encima de LO SUYO'.
El maximo z de cada sensor, que es su hora mas atipica:
S01 h03 z = -1.74 (19.0 grados)
S02 h15 z = +1.78 (23.3 grados)
S03 h03 z = -1.91 (15.5 grados)
S04 h15 z = +1.95 (23.9 grados)
S05 h03 z = -1.91 (18.2 grados)
S06 h03 z = -1.88 (18.0 grados)
S07 h02 z = -1.61 (21.2 grados)
S08 h15 z = +1.99 (23.4 grados)
S09 h03 z = -1.85 (19.1 grados)
S10 h15 z = +1.71 (20.9 grados)
Sin centrar, la hora mas calurosa seria siempre la del sensor mas
caluroso. Centrando, cada uno se compara consigo mismo.
El z-score vuelve en la leccion 3.7 como criterio de atipico, y en
el ejercicio EJ-3.2 sobre las notas de los estudiantes.
El z-score - (x - media) / desvío - es la aplicación
más común de broadcasting, y en la grilla de sensores es una sola línea con dos
broadcasts:
limpias = np.where(lecturas > 50, np.nan, lecturas)
medias = np.nanmean(limpias, axis=1, keepdims=True) # (10, 1)
desvios = np.nanstd(limpias, axis=1, keepdims=True) # (10, 1)
z = (limpias - medias) / desvios # (10, 24)
Después de eso, cada sensor tiene media 0 y desvío 1 - aunque midan
temperaturas distintas. Un z de 2,1 significa lo mismo en el depósito que
en la sala de servidores: «dos desvíos por encima de lo suyo».
La primera línea no es un adorno. Con el 999.9 adentro, la media de S05
da 60,65 y su desvío es enorme: el z-score de ese sensor no significa nada.
Marcado como ausente, su media pasa a 19,81 y las otras 23 horas se vuelven
comparables.
La consecuencia práctica se ve en el resultado: sin centrar, la hora más calurosa sería siempre la del sensor más caluroso. Centrando, cada uno se compara consigo mismo.
newaxis, el producto exterior y la memoria
import numpy as np
sensores = np.arange(1, 11) # (10,)
horas = np.arange(24) # (24,)
print("sensores:", sensores.shape, "| horas:", horas.shape)
print()
print("Tres formas de agregar una dimension, y son la misma:")
print(" sensores.reshape(-1, 1).shape ->", sensores.reshape(-1, 1).shape)
print(" sensores[:, np.newaxis].shape ->", sensores[:, np.newaxis].shape)
print(" sensores[:, None].shape ->", sensores[:, None].shape)
print(" np.newaxis ES None: se escribe asi porque se lee mejor.")
print()
print("Y del otro lado, para hacerlo fila:")
print(" horas[np.newaxis, :].shape ->", horas[np.newaxis, :].shape)
print()
print("Ahora el caso que sorprende: dos vectores dan una MATRIZ.")
combinaciones = sensores[:, None] * 100 + horas[None, :]
print(" sensores[:, None] * 100 + horas[None, :] -> shape",
combinaciones.shape)
print(" primeras 3 filas, primeras 6 columnas:")
print(combinaciones[:3, :6])
print(" Cada sensor se combino con cada hora: 10 x 24 = 240 numeros")
print(" a partir de 34. Se llama producto exterior, y es como se")
print(" arman grillas de coordenadas.")
print()
print("Sirve tambien para tablas de distancias, o para una tabla de")
print("multiplicar sin bucles:")
n = np.arange(1, 6)
print(" n[:, None] * n[None, :] ->")
print(n[:, None] * n[None, :])
print()
print("=" * 62)
print("EL COSTO ESCONDIDO")
print("=" * 62)
print("Broadcasting no copia los datos para operar, pero el RESULTADO si")
print("ocupa el tamano completo.")
a = np.arange(10)
b = np.arange(24)
resultado = a[:, None] + b[None, :]
print(f" a: {a.nbytes} bytes | b: {b.nbytes} bytes | "
f"a[:, None] + b: {resultado.nbytes} bytes")
print(" Con 10 y 24 no importa. Con dos vectores de 100.000:")
print(" 100000 x 100000 x 8 bytes = 80 GB. No entra.")
print(" Es el error de memoria mas comun de NumPy, y siempre viene de")
print(" un broadcast que uno no queria hacer.")
print()
print("La senal de alarma: mirar el shape ANTES de dejar correr la linea.")
print(" np.broadcast_shapes((10,), (24,)) ->", end=" ")
try:
print(np.broadcast_shapes((10,), (24,)))
except ValueError as error:
print(type(error).__name__ + ":", error)
print(" np.broadcast_shapes((10, 1), (1, 24)) ->",
np.broadcast_shapes((10, 1), (1, 24)))
print(" np.broadcast_shapes((10, 24), (24,)) ->",
np.broadcast_shapes((10, 24), (24,)))
print(" Contesta la pregunta sin construir nada.")
sensores: (10,) | horas: (24,)
Tres formas de agregar una dimension, y son la misma:
sensores.reshape(-1, 1).shape -> (10, 1)
sensores[:, np.newaxis].shape -> (10, 1)
sensores[:, None].shape -> (10, 1)
np.newaxis ES None: se escribe asi porque se lee mejor.
Y del otro lado, para hacerlo fila:
horas[np.newaxis, :].shape -> (1, 24)
Ahora el caso que sorprende: dos vectores dan una MATRIZ.
sensores[:, None] * 100 + horas[None, :] -> shape (10, 24)
primeras 3 filas, primeras 6 columnas:
[[100 101 102 103 104 105]
[200 201 202 203 204 205]
[300 301 302 303 304 305]]
Cada sensor se combino con cada hora: 10 x 24 = 240 numeros
a partir de 34. Se llama producto exterior, y es como se
arman grillas de coordenadas.
Sirve tambien para tablas de distancias, o para una tabla de
multiplicar sin bucles:
n[:, None] * n[None, :] ->
[[ 1 2 3 4 5]
[ 2 4 6 8 10]
[ 3 6 9 12 15]
[ 4 8 12 16 20]
[ 5 10 15 20 25]]
==============================================================
EL COSTO ESCONDIDO
==============================================================
Broadcasting no copia los datos para operar, pero el RESULTADO si
ocupa el tamano completo.
a: 80 bytes | b: 192 bytes | a[:, None] + b: 1920 bytes
Con 10 y 24 no importa. Con dos vectores de 100.000:
100000 x 100000 x 8 bytes = 80 GB. No entra.
Es el error de memoria mas comun de NumPy, y siempre viene de
un broadcast que uno no queria hacer.
La senal de alarma: mirar el shape ANTES de dejar correr la linea.
np.broadcast_shapes((10,), (24,)) -> ValueError: shape mismatch: objects cannot be broadcast to a single shape. Mismatch is between arg 0 with shape (10,) and arg 1 with shape (24,).
np.broadcast_shapes((10, 1), (1, 24)) -> (10, 24)
np.broadcast_shapes((10, 24), (24,)) -> (10, 24)
Contesta la pregunta sin construir nada.
| Expresión | De (10,) a |
|---|---|
a.reshape(-1, 1) | (10, 1) - columna |
a[:, np.newaxis] | (10, 1) - columna |
a[:, None] | (10, 1) - np.newaxis es None |
a[np.newaxis, :] | (1, 10) - fila |
Combinando una columna con una fila se obtiene una matriz:
(10, 1) con (1, 24) da (10, 24). Es el producto
exterior, y es como se arman grillas de coordenadas, tablas de distancias o una tabla
de multiplicar sin bucles.
Broadcasting no copia los datos para operar, pero el
resultado ocupa el tamaño completo. Dos vectores de 100.000 elementos
combinados así producen una matriz de 100.000 × 100.000 × 8 bytes: 80 GB, que es lo que NumPy informa como 74.5 GiB.
Es el error de memoria más común de NumPy y siempre viene de un broadcast
que uno no quería hacer. np.broadcast_shapes(a.shape, b.shape) contesta
qué forma va a tener el resultado sin construir nada.
Dónde se rompe
Causa: el segundo array tiene una dimensión de menos y, alineado a la derecha, su 10 choca contra las 24 columnas.
Arreglo: keepdims=True en la reducción, o
reshape(-1, 1). El mensaje dice las dos formas: leelas alineadas a la
derecha y la dimensión que choca salta a la vista.
Causa: un broadcast involuntario entre dos vectores largos, casi siempre
por un [:, None] de más.
Arreglo: np.broadcast_shapes() antes de correr la línea. Si
el shape esperado te sorprende, ahí está el problema.
Causa: usaste (1, n) donde querías (n, 1), o al
revés.
Arreglo: mirá el shape del resultado. Si esperabas
(10, 24) y obtuviste (24, 10), invertí el
reshape.
nan
Causa: esa fila tiene un NaN y usaste mean en
vez de nanmean: restar nan deja la fila entera en
nan.
Arreglo: la familia nan* (lección 3.6). Y si el desvío da 0,
la división produce inf, que es otro problema.
Recetario
| Expresión | Qué hace |
|---|---|
a * 2 | El escalar se repite: el caso más simple |
a - a.mean(axis=0) | Centrar por columna, sin nada más |
a - a.mean(axis=1, keepdims=True) | Centrar por fila |
(a - m) / s | El z-score, con m y s en (n, 1) |
v.reshape(-1, 1) | Vector a columna |
v[:, None] | Lo mismo, más corto |
v[None, :] | Vector a fila |
col + fil | Producto exterior: (n, 1) con (1, m) da (n, m) |
np.broadcast_shapes(x, y) | Qué forma sale, sin calcular |
np.broadcast_to(v, forma) | El estirado explícito, como vista de solo lectura |