Saltar al contenido

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

Paso a pasoLas reglas de broadcasting, leidas de atras para adelante
  1. 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
  2. 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
  3. 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)
  4. 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)
  5. 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)
  6. 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ónQué hace
Las dos dimensiones son igualesPasa
Una de las dos vale 1La estira hasta la otra
A una le falta esa dimensiónLa agrega como 1, y la estira
Cualquier otra cosaValueError
EjemploLas tres reglas de broadcasting
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).
Por qué (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).

La regla mnemotécnica

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

EjemploCentrar y escalar: el z-score, en una linea
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».

Marcar el imposible antes de calcular

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

Ejemplonewaxis, el producto exterior y el costo escondido
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.
Tres formas de agregar una dimensión, y son la misma
ExpresiónDe (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.

El costo escondido

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

ValueError: operands could not be broadcast together with shapes (10,24) (10,)

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.

MemoryError: Unable to allocate 74.5 GiB for an array

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.

(sin error) el resultado quedó con una fila de más

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.

(sin error) el z-score de una fila da 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

Broadcasting
ExpresiónQué hace
a * 2El 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) / sEl 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 + filProducto 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

Ponete a prueba

1. ¿Cómo alinea NumPy dos formas para decidir si son compatibles?Nivel 1 · Recordar
2. ¿Qué shape tiene np.nanmean(a, axis=1, keepdims=True) si a es (10, 24)?Nivel 1 · Recordar
3. a es (10, 24) y v es (10,). ¿Por qué a - v falla?Nivel 2 · Predecir la salida
4. col es (10, 1) y fil es (1, 24). ¿Qué devuelve col + fil?Nivel 2 · Predecir la salida
5. Hay que restarle a cada sensor su propio promedio, sobre una grilla (10, 24). Las cuatro corren o parecen correr: ¿cuál conviene?Nivel 3 · Elegir entre dos casi correctas
6. ¿Para qué sirve marcar el 999.9 como NaN antes de calcular el z-score de cada sensor?Nivel 3 · Elegir entre dos casi correctas
7. Este código calcula distancias entre 100.000 puntos y muere con MemoryError: Unable to allocate 74.5 GiB. ¿Qué pasó?
x = np.arange(100_000, dtype=float)   # 800 KB
diferencias = x[:, None] - x[None, :]
Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad