NumPy: arrays, dtypes, broadcasting y operaciones vectorizadas

El corazón de NumPy es el ndarray: un bloque contiguo de memoria donde todos los elementos tienen exactamente el mismo tipo. Esa restricción —que parece una limitación— es precisamente lo que le da su velocidad. A diferencia de una lista Python, donde cada elemento es un objeto independiente con su propio overhead, un ndarray de float64 es literalmente bytes consecutivos en RAM. Las operaciones vectorizadas delegan el loop a C y BLAS, ejecutándose órdenes de magnitud más rápido que cualquier for en Python puro.

El dtype no es un detalle cosmético: define cuántos bytes ocupa cada elemento, el rango de valores representables y qué operaciones tienen sentido. Cuando mezclas tipos, NumPy hace upcasting automático hacia el tipo más general (int32 + float64float64). Si construyes un array a partir de enteros y accidentalmente introduces un float, todos los enteros se convierten. Eso puede triplicar el uso de memoria sin que te des cuenta.

El broadcasting es el mecanismo que permite operar arrays de formas diferentes sin copiar datos. La regla central: NumPy alinea las formas por la derecha y expande las dimensiones de tamaño 1 (o ausentes) para que coincidan. Si las formas no son compatibles bajo esa regla, obtienes un ValueError. Entender cuándo broadcasting copia implícitamente datos versus cuándo opera sobre vistas es clave para escribir código eficiente.

Un for sobre un ndarray es casi siempre una señal de que hay una operación vectorizada o de broadcasting que resuelve lo mismo en una fracción del tiempo. La única excepción justificable es lógica de dependencia secuencial donde cada iteración depende del resultado anterior.

import numpy as np

# ── Constructores ─────────────────────────────────────────────────────────────
prices = np.array([10.5, 20.0, 15.75, 8.3, 42.1], dtype=np.float64)
zeros_matrix = np.zeros((3, 4), dtype=np.int32)
identity_like = np.ones((3, 3))
steps = np.arange(0, 10, 2)          # [0, 2, 4, 6, 8]
grid = np.linspace(0.0, 1.0, 5)      # 5 puntos equiespaciados en [0, 1]

# ── Dtype y upcasting ─────────────────────────────────────────────────────────
int_arr = np.array([1, 2, 3], dtype=np.int32)
mixed = int_arr + 0.5                 # resultado es float64, no int32
print(mixed.dtype)                    # float64

# ── Array 2D y slicing multidimensional ───────────────────────────────────────
data = np.arange(24, dtype=np.float32).reshape(4, 6)
# data[filas, columnas]: selecciona filas 1-2 y columnas 2-4
submatrix = data[1:3, 2:5]
print(submatrix.shape)               # (2, 3)
# submatrix es una *vista*, no una copia; modificarla afecta data
submatrix[0, 0] = 999.0
print(data[1, 2])                    # 999.0

# ── Operaciones vectorizadas ───────────────────────────────────────────────────
revenue = prices * np.array([100, 200, 150, 300, 80])   # multiplicación elemento a elemento
discounted = prices * 0.9             # scalar broadcast sobre todo el array
above_threshold = prices > 15.0       # array booleano, sin loop

# ── Broadcasting con arrays 2D ────────────────────────────────────────────────
weights = np.array([[1.0, 2.0, 3.0],
                    [4.0, 5.0, 6.0],
                    [7.0, 8.0, 9.0]])

# row_bias tiene forma (3,) → NumPy la trata como (1, 3) y la expande a (3, 3)
row_bias = np.array([10.0, 20.0, 30.0])
adjusted = weights + row_bias

# col_bias tiene forma (3, 1) → se expande a (3, 3) en la dirección de columnas
col_bias = np.array([[100.0], [200.0], [300.0]])
scaled = weights + col_bias

# ── np.where, sum y mean con axis ─────────────────────────────────────────────
# np.where: equivalente vectorizado del operador ternario
clamped = np.where(prices > 15.0, prices, 0.0)   # 0.0 si precio ≤ 15

scores = np.array([[85, 90, 78],
                   [92, 88, 95],
                   [70, 65, 80]], dtype=np.float64)

col_totals = np.sum(scores, axis=0)    # suma cada columna → shape (3,)
row_means  = np.mean(scores, axis=1)   # media de cada fila → shape (3,)
print("Totales por columna:", col_totals)
print("Medias por fila:    ", row_means)

# ── Por qué el loop es una señal de alarma ────────────────────────────────────
# ❌ Así NO: O(n) llamadas Python con overhead de intérprete
slow_result = np.empty_like(prices)
for i, p in enumerate(prices):
    slow_result[i] = p * 1.21

# ✅ Así SÍ: un único ufunc en C
fast_result = prices * 1.21

Desglose del código

Los constructores no son intercambiables: np.arange trabaja con pasos enteros o flotantes y devuelve un número de elementos determinado por el rango; np.linspace recibe el número de puntos deseado y garantiza que el extremo final esté incluido. Para mallas de valores continuos (parámetros de modelos, ejes de gráficas), linspace es la elección natural.

El bloque de slicing multidimensional demuestra algo crítico: data[1:3, 2:5] devuelve una vista, no una copia. La memoria es compartida. Si necesitas independencia, usa .copy() explícitamente. Esto permite encadenar operaciones sin duplicar arrays gigantes, pero también genera bugs silenciosos si no lo tienes presente.

En el bloque de broadcasting, fíjate en la diferencia entre row_bias (forma (3,)) y col_bias (forma (3, 1)). NumPy alinea por la derecha: (3,) se convierte en (1, 3) y se expande hacia las filas; (3, 1) se expande hacia las columnas. Esa distinción de una dimensión explícita cambia completamente el resultado. Muchos bugs de broadcasting vienen de no controlar si el vector es fila o columna.

np.where no evalúa condicionalmente: calcula ambas ramas para todos los elementos y luego selecciona. Esto es irrelevante con arrays simples, pero importa si las ramas tienen efectos secundarios o son computacionalmente costosas.

np.sum(axis=0) colapsa la dimensión 0 (filas), produciendo la suma de cada columna. np.mean(axis=1) colapsa la dimensión 1 (columnas), produciendo la media de cada fila. El patrón es consistente para todos los ufuncs con axis.

Errores que debes conocer

Error: Asumir que un slice es independiente del array original y modificarlo sin querer.

# ❌ Wrong
original = np.arange(9).reshape(3, 3)
patch = original[0:2, 0:2]
patch[:] = 0   # modifica original también

# ✅ Right
patch = original[0:2, 0:2].copy()
patch[:] = 0   # original intacto

Añadir .copy() materializa una nueva región de memoria, rompiendo el vínculo con el array fuente.


Error: Broadcasting silencioso cuando las formas son accidentalmente compatibles pero el resultado no es el esperado.

# ❌ Wrong: ambos son (3,), se suman elemento a elemento, no producto externo
a = np.array([1.0, 2.0, 3.0])
b = np.array([10.0, 20.0, 30.0])
wrong = a + b   # [11, 22, 33], no una matriz 3×3

# ✅ Right: reshape explícito para broadcasting intencional
outer = a[:, np.newaxis] + b   # shape (3, 1) + (3,) → (3, 3)

np.newaxis inserta una dimensión de tamaño 1, forzando el broadcasting en la dirección correcta. Cualquier operación entre arrays 1D debería tener la forma explícita documentada si la intención es producir una matriz.


Error: Construir un array con dtype inadecuado y perder precisión silenciosamente.

# ❌ Wrong: int32 trunca el resultado de divisiones
counts = np.array([3, 7, 2], dtype=np.int32)
ratios = counts / 10   # en Python 3 produce float64, pero...
flags  = np.array([True, False, True])
mixed  = counts + flags   # flags se convierten a int32, OK aquí
# el problema real: operar sobre el array creyendo que es float cuando es int
wrong_mean = counts // 2   # división entera, no lo que querías

# ✅ Right: especifica el dtype al construir, o convierte explícitamente
counts_f = counts.astype(np.float64)
correct_mean = counts_f / 2   # [1.5, 3.5, 1.0]

.astype() crea un nuevo array con el tipo destino. Hacer la conversión explícita en el punto de construcción es mejor que depender del upcasting implícito más adelante.

188

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio