NumPy: Arrays y Computación Científica en Python
NumPy es la biblioteca fundamental para computación científica en Python. Proporciona arrays multidimensionales de alto rendimiento y funciones matemáticas vectorizadas que son entre 10 y 100 veces más rápidas que las listas Python puras.
Instalación y conceptos básicos
pip install numpy
import numpy as np
# Crear arrays desde listas
arr_1d = np.array([1, 2, 3, 4, 5])
arr_2d = np.array([[1, 2, 3], [4, 5, 6]])
print(arr_1d.shape) # (5,)
print(arr_2d.shape) # (2, 3)
print(arr_2d.ndim) # 2
print(arr_2d.size) # 6
print(arr_2d.dtype) # int64
Creación de arrays especiales
# Ceros y unos
zeros = np.zeros((3, 4)) # array 3x4 de ceros float64
ones = np.ones((2, 3), dtype=int) # array 2x3 de unos enteros
# Rangos
arange = np.arange(0, 10, 2) # [0, 2, 4, 6, 8]
linspace = np.linspace(0, 1, 5) # [0.0, 0.25, 0.5, 0.75, 1.0]
logspace = np.logspace(0, 3, 4) # [1, 10, 100, 1000]
# Identidad y diagonal
eye = np.eye(3) # matriz identidad 3x3
diag = np.diag([1, 2, 3]) # matriz diagonal
# Aleatoriedad reproducible
rng = np.random.default_rng(seed=42)
rand_arr = rng.random((3, 3)) # floats [0, 1)
rand_int = rng.integers(0, 100, (5,)) # enteros [0, 100)
normal = rng.standard_normal((100,)) # distribución normal
Tipos de datos (dtypes)
# Especificar dtype explícitamente
float32_arr = np.array([1.5, 2.5], dtype=np.float32) # 4 bytes/elem
int16_arr = np.array([100, 200], dtype=np.int16) # 2 bytes/elem
bool_arr = np.array([0, 1, 0], dtype=bool) # True/False
# Conversión de dtype
converted = float32_arr.astype(np.float64)
# Tipos comunes
# np.float64 → double precision (default para floats)
# np.int64 → entero 64-bit (default para enteros)
# np.complex128 → complejo 128-bit
# np.uint8 → entero sin signo 8-bit (imágenes)
Indexación y slicing
arr = np.arange(20).reshape(4, 5)
# [[ 0, 1, 2, 3, 4],
# [ 5, 6, 7, 8, 9],
# [10, 11, 12, 13, 14],
# [15, 16, 17, 18, 19]]
# Indexación básica
print(arr[1, 3]) # 8
print(arr[2]) # [10, 11, 12, 13, 14] — fila 2
# Slicing
print(arr[1:3, 2:4]) # submatriz filas 1-2, columnas 2-3
print(arr[:, 0]) # primera columna completa
print(arr[::2]) # filas pares (0, 2)
# Indexación booleana
mask = arr > 10
print(arr[mask]) # [11, 12, 13, 14, 15, 16, 17, 18, 19]
# Indexación fancy (con arrays de índices)
rows = np.array([0, 2])
cols = np.array([1, 3])
print(arr[rows, cols]) # [arr[0,1], arr[2,3]] = [1, 13]
# Nota: slicing devuelve una vista (view), no una copia
view = arr[1:3]
view[:] = 0 # modifica arr también
# Para copiar explícitamente
copy = arr[1:3].copy()
Reshaping y manipulación de forma
arr = np.arange(24)
# Reshape
mat_4x6 = arr.reshape(4, 6)
mat_2x3x4 = arr.reshape(2, 3, 4) # tensor 3D
flat = mat_4x6.flatten() # copia aplanada
ravel = mat_4x6.ravel() # vista aplanada (si posible)
# -1 infiere la dimensión automáticamente
auto = arr.reshape(-1, 6) # (4, 6)
auto2 = arr.reshape(3, -1) # (3, 8)
# Transponer
T = mat_4x6.T # (6, 4)
transposed = np.transpose(mat_2x3x4, (2, 0, 1)) # permutación de ejes
# Expandir/reducir dimensiones
col = arr[:5].reshape(-1, 1) # (5,) → (5, 1)
expanded = np.expand_dims(arr[:5], axis=0) # (5,) → (1, 5)
squeezed = np.squeeze(expanded) # (1, 5) → (5,)
# Stack y concatenación
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
horizontal = np.hstack([a, b]) # [1, 2, 3, 4, 5, 6]
vertical = np.vstack([a, b]) # [[1,2,3],[4,5,6]]
stacked = np.stack([a, b], axis=0) # [[1,2,3],[4,5,6]]
Broadcasting
Broadcasting permite operaciones entre arrays de formas distintas siguiendo reglas específicas.
# Reglas de broadcasting:
# 1. Si los arrays tienen diferente número de dimensiones, se añaden 1s a la izquierda
# 2. Los tamaños deben ser iguales o uno de ellos debe ser 1
# Ejemplo: sumar escalar a array 2D
mat = np.ones((3, 4))
result = mat + 10 # funciona: escalar se expande a (3,4)
# Array 1D + Array 2D
row = np.array([1, 2, 3, 4]) # shape (4,)
col = np.array([[10], [20], [30]]) # shape (3, 1)
result = row + col
# [[11, 12, 13, 14],
# [21, 22, 23, 24],
# [31, 32, 33, 34]]
# Caso práctico: normalizar filas de una matriz
matrix = rng.random((100, 5))
row_means = matrix.mean(axis=1, keepdims=True) # (100, 1)
row_stds = matrix.std(axis=1, keepdims=True) # (100, 1)
normalized = (matrix - row_means) / row_stds # broadcasting: (100,5)
Operaciones matemáticas vectorizadas
a = np.array([1.0, 4.0, 9.0, 16.0])
# Funciones universales (ufuncs) — operan elemento a elemento
np.sqrt(a) # [1., 2., 3., 4.]
np.exp(a) # e^x para cada elemento
np.log(a) # logaritmo natural
np.log10(a) # log base 10
np.sin(a) # seno
np.abs(np.array([-1, -2, 3])) # [1, 2, 3]
# Operaciones entre arrays
b = np.array([2.0, 3.0, 4.0, 5.0])
np.add(a, b) # equivalente a a + b
np.multiply(a, b) # a * b elemento a elemento
np.dot(a, b) # producto punto = suma(a*b)
# Reducciones
a.sum() # suma total
a.mean() # media
a.std() # desviación estándar
a.min(), a.max() # mínimo y máximo
a.argmin(), a.argmax() # índices del mín/máx
# Reducciones por eje
mat = np.array([[1,2,3],[4,5,6]])
mat.sum(axis=0) # [5, 7, 9] — suma por columnas
mat.sum(axis=1) # [6, 15] — suma por filas
mat.cumsum() # suma acumulada aplanada
# Comparaciones
np.maximum(a, b) # máximo elemento a elemento
np.minimum(a, b) # mínimo elemento a elemento
np.clip(a, 2, 10) # recortar entre 2 y 10
Álgebra lineal
from numpy.linalg import (
det, inv, solve, eig, eigh, svd, norm, matrix_rank, lstsq
)
A = np.array([[3, 1], [1, 2]], dtype=float)
b = np.array([9, 8], dtype=float)
# Determinante e inversa
print(det(A)) # 5.0
A_inv = inv(A)
# Resolver sistema Ax = b
x = solve(A, b)
print(x) # [2. 3.]
print(np.allclose(A @ x, b)) # True
# Producto matricial
C = np.eye(3)
D = rng.random((3, 3))
product = C @ D # equivalente a np.matmul(C, D)
# Valores y vectores propios
values, vectors = eig(A)
# Descomposición en valores singulares (SVD)
M = rng.random((4, 3))
U, S, Vt = svd(M, full_matrices=False)
# M ≈ U @ np.diag(S) @ Vt
# Norma de vector/matriz
norm(x) # norma L2 euclidiana
norm(A, ord='fro') # norma de Frobenius
# Mínimos cuadrados (regresión lineal)
X = np.column_stack([np.ones(10), np.arange(10)])
y = 2 * np.arange(10) + 1 + rng.standard_normal(10)
coeffs, residuals, rank, sv = lstsq(X, y, rcond=None)
Transformada de Fourier (FFT)
# Señal de ejemplo
t = np.linspace(0, 1, 1000, endpoint=False) # 1 segundo, 1000 muestras
signal = np.sin(2 * np.pi * 50 * t) + 0.5 * np.sin(2 * np.pi * 120 * t)
# FFT
fft_vals = np.fft.fft(signal)
freqs = np.fft.fftfreq(len(signal), d=1/1000) # frecuencias en Hz
magnitude = np.abs(fft_vals)
# Solo frecuencias positivas
positive_mask = freqs > 0
dominant_freqs = freqs[positive_mask][np.argsort(magnitude[positive_mask])[-5:]]
print(dominant_freqs) # aproximadamente [50, 120]
# FFT 2D (procesado de imágenes)
from numpy.fft import fft2, ifft2, fftshift
image = rng.random((256, 256))
fft2d = fft2(image)
spectrum = fftshift(fft2d) # centrar frecuencia cero
Rendimiento y memoria
import time
# Comparación: lista Python vs NumPy
n = 10_000_000
py_list = list(range(n))
np_arr = np.arange(n, dtype=np.float64)
t0 = time.perf_counter()
total_py = sum(x**2 for x in py_list)
t1 = time.perf_counter()
total_np = (np_arr**2).sum()
t2 = time.perf_counter()
print(f"Python: {t1-t0:.3f}s")
print(f"NumPy: {t2-t1:.4f}s") # típicamente 50-100x más rápido
# Optimizar memoria con dtypes apropiados
# float64 (default): 8 bytes/elemento
# float32: 4 bytes/elemento — suficiente para ML
# int16: 2 bytes/elemento — para valores ≤ 32767
# Arrays de solo lectura (seguridad)
immutable = np.arange(10)
immutable.flags.writeable = False
# immutable[0] = 99 # ValueError
# Guardar y cargar arrays
np.save('array.npy', np_arr) # formato binario eficiente
loaded = np.load('array.npy')
np.savez('arrays.npz', x=np_arr, y=np_arr[:100]) # múltiples arrays
data = np.load('arrays.npz')
print(data['x'].shape, data['y'].shape)
# Formato texto (menos eficiente)
np.savetxt('matrix.csv', mat_4x6, delimiter=',', fmt='%.4f')
loaded_csv = np.genfromtxt('matrix.csv', delimiter=',')
Caso práctico: pipeline de procesado de señal
import numpy as np
def analizar_senial(datos_brutos: np.ndarray, fs: float) -> dict:
"""
Analiza una señal: elimina DC, filtra por ventana, calcula espectro.
Args:
datos_brutos: array 1D de muestras
fs: frecuencia de muestreo en Hz
Returns:
dict con estadísticas y espectro dominante
"""
# 1. Eliminar componente DC (media)
datos = datos_brutos - datos_brutos.mean()
# 2. Aplicar ventana de Hann para reducir spectral leakage
ventana = np.hanning(len(datos))
datos_ventaneados = datos * ventana
# 3. FFT y magnitud
N = len(datos)
fft_vals = np.fft.rfft(datos_ventaneados) # solo mitad positiva
magnitudes = np.abs(fft_vals) * 2 / N
freqs = np.fft.rfftfreq(N, d=1/fs)
# 4. Frecuencias dominantes
idx_top5 = np.argsort(magnitudes)[-5:][::-1]
return {
'media': datos_brutos.mean(),
'std': datos_brutos.std(),
'rms': np.sqrt((datos**2).mean()),
'pico_positivo': datos.max(),
'pico_negativo': datos.min(),
'frecuencias_dominantes_hz': freqs[idx_top5].tolist(),
'amplitudes_dominantes': magnitudes[idx_top5].tolist(),
}
# Ejemplo de uso
rng = np.random.default_rng(42)
fs = 1000.0 # 1 kHz
t = np.arange(0, 5, 1/fs) # 5 segundos
# Señal con dos armónicos + ruido
señal = (
3.0 * np.sin(2 * np.pi * 60 * t) +
1.5 * np.sin(2 * np.pi * 180 * t) +
0.3 * rng.standard_normal(len(t))
)
resultado = analizar_senial(señal, fs)
for k, v in resultado.items():
print(f"{k}: {v}")
Buenas prácticas
- Evitar bucles Python sobre arrays NumPy — usa operaciones vectorizadas y ufuncs siempre que sea posible. Si necesitas iterar, considera
np.vectorizeonumba. - Cuidado con vistas vs copias — las operaciones de slicing devuelven vistas; modifícalas si es intencional, usa
.copy()si no lo es. - Usar
np.random.default_rng(seed)en lugar denp.random.seed()(API moderna, reproducible y thread-safe). - Especificar dtype cuando el tamaño de memoria importa:
float32para redes neuronales,uint8para imágenes de 8 bits. - Prefer
@sobrenp.dotpara multiplicación de matrices — más legible y compatible con PyTorch/TensorFlow. - Perfilar con
%timeiten Jupyter otime.perf_counter()para detectar cuellos de botella antes de optimizar.
Conversiones relacionadas
Conversiones frecuentes del catálogo: