NumPy: pensar en arreglos

Tema 3 · Fase 1, Programar con datos · Toda la ruta

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 20 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

De la lista al arreglo

import numpy as np

lista = [1, 2, 3, 4]
arreglo = np.array([1, 2, 3, 4])

print(lista * 2)     # la lista repetida dos veces
print(arreglo * 2)   # cada número multiplicado por 2
print(arreglo + 10)
print(arreglo ** 2)
materias = ["Matemática", "Física", "Lenguaje", "Biología"]
# Notas sobre 100: una fila por estudiante, una columna por materia
notas = np.array([
    [72, 65, 80, 58],
    [45, 50, 62, 70],
    [88, 92, 75, 81],
    [60, 48, 55, 66],
    [95, 85, 90, 88],
    [38, 52, 47, 61],
])

print("shape:", notas.shape)   # (filas, columnas)
print("ndim: ", notas.ndim)    # cuántas dimensiones tiene
print("size: ", notas.size)    # cuántos números hay en total
print("dtype:", notas.dtype)   # de qué tipo es cada número
print("En décimos:", (notas / 10).dtype)
print(np.zeros((2, 3)))          # una tabla de ceros de 2 × 3
print(np.ones(4))                # cuatro unos
print(np.arange(0, 10, 2))       # como range, pero da un arreglo
print(np.linspace(0, 1, 5))      # 5 números parejos de 0 a 1

# Números al azar, con semilla fija para que salgan siempre iguales
rng = np.random.default_rng(42)
print(rng.integers(1, 7, size=10))          # diez dados
# Números al azar alrededor de 0, casi todos entre -2 y 2: la
# «campana» de la distribución normal, que verás en el tema 5
print(rng.normal(0, 1, size=(2, 3)).round(2))

Indexar, rebanar y filtrar

print(notas[0])          # la fila 0: el primer estudiante
print(notas[0, 2])       # fila 0, columna 2: su nota de Lenguaje
print(notas[-1])         # la última fila
print(notas[:, 0])       # todas las filas, columna 0: Matemática
print(notas[1:3, :2])    # filas 1 y 2, columnas 0 y 1
print(notas[::2, -1])    # una fila sí y otra no, última columna
aprobado = notas >= 51
print(aprobado[:2])                  # las dos primeras filas
print("Notas reprobadas:", notas[notas < 51])
print("Cuántas notas aprobadas:", aprobado.sum())
# Las notas de Matemática de quienes aprobaron Física
print(notas[notas[:, 1] >= 51, 0])

Los ejes

M = np.array([[2, 5, 1, 4],
              [3, 0, 6, 2],
              [1, 4, 2, 7]])

print("Sin eje:  ", M.sum())
print("axis=0:   ", M.sum(axis=0), "forma", M.sum(axis=0).shape)
print("axis=1:   ", M.sum(axis=1), "forma", M.sum(axis=1).shape)
import matplotlib.pyplot as plt

filas, columnas = M.shape
fig, ax = plt.subplots()
for i in range(filas):
    for j in range(columnas):
        # La fila i se dibuja más abajo cuanto mayor es i
        ax.add_patch(plt.Rectangle((j, -i), 1, 1, fill=False,
                                   edgecolor="C0", linewidth=1.5))
        ax.text(j + 0.5, -i + 0.5, M[i, j], ha="center", va="center")
for j, s in enumerate(M.sum(axis=0)):
    ax.text(j + 0.5, -filas + 0.1, s, ha="center", va="center",
            color="C2")
for i, s in enumerate(M.sum(axis=1)):
    ax.text(columnas + 0.6, -i + 0.5, s, ha="center", va="center",
            color="C2")
# Las flechas de los ejes
flecha = dict(arrowstyle="-|>", color="C0", linewidth=2)
ax.annotate("", xy=(-0.4, -filas + 1), xytext=(-0.4, 1),
            arrowprops=flecha)
ax.annotate("", xy=(columnas, 1.4), xytext=(0, 1.4), arrowprops=flecha)
ax.text(-0.7, -0.5, "eje 0", rotation=90, ha="center", va="center")
ax.text(columnas / 2, 1.75, "eje 1", ha="center")
ax.text(columnas / 2, -filas - 0.5, "sum(axis=0): forma (4,)",
        ha="center", color="C2")
ax.text(columnas + 1.2, -filas / 2 + 1, "sum(axis=1): forma (3,)",
        rotation=-90, ha="center", va="center", color="C2")
ax.set_xlim(-1, columnas + 1.5)
ax.set_ylim(-filas - 0.9, 2.1)
ax.set_aspect("equal")
ax.axis("off")
plt.show()
prom_materia = notas.mean(axis=0)     # un promedio por materia
prom_estudiante = notas.mean(axis=1)  # un promedio por estudiante

for materia, p in zip(materias, prom_materia):
    print(f"{materia:<11} {p:5.1f}")
print("Por estudiante:", prom_estudiante.round(1))
print("Mejor estudiante: fila", prom_estudiante.argmax())
print("Nota más alta de cada materia:", notas.max(axis=0))

Cambiar la forma: reshape y transpuesta

a = np.arange(12)
print(a, a.shape)

b = a.reshape(3, 4)
print(b)
print("b.T:", b.T.shape)
print(b.T)
print(a.reshape(2, 2, 3).shape)
print(a.reshape(-1, 6).shape)    # -1: «calcula tú este número»

Broadcasting: operar con formas distintas

print(notas.shape, prom_materia.shape)
diferencia = notas - prom_materia      # (6, 4) - (4,)
print(diferencia[:2].round(1))
prom_col = notas.mean(axis=1, keepdims=True)
print(prom_col.shape)                              # (6, 1)
print((notas - prom_col)[:2].round(1))
# Lo mismo, agregando un eje al vector de forma (6,)
print(prom_estudiante[:, np.newaxis].shape)

Producto punto: medir el parecido

# fútbol, música, lectura, videojuegos
ana = np.array([5, 1, 0, 4])
beto = np.array([4, 0, 1, 5])
caro = np.array([0, 5, 4, 1])

print("Ana · Beto =", ana @ beto)
print("Ana · Caro =", ana @ caro)
print("A mano:    ", np.sum(ana * beto))
# Solo los dos primeros gustos (fútbol y música), para dibujar en el plano
for nombre, v in [("Ana", ana[:2]), ("Beto", beto[:2]),
                  ("Caro", caro[:2])]:
    plt.annotate("", xy=v, xytext=(0, 0),
                 arrowprops=dict(arrowstyle="-|>", color="C0", lw=2))
    plt.text(v[0] + 0.15, v[1] + 0.15, nombre)
# Solo para dibujar:
plt.gca().set_aspect("equal")
plt.xlim(-0.5, 6)
plt.ylim(-0.5, 6)
plt.xlabel("Fútbol")
plt.ylabel("Música")
plt.title("Cada estudiante es una flecha")
plt.show()
def parecido(a, b):
    """Similitud coseno: el producto punto de los vectores normalizados."""
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))

print(f"Norma de Ana: {np.linalg.norm(ana):.2f}")
print(f"Ana y Beto: {parecido(ana, beto):.2f}")
print(f"Ana y Caro: {parecido(ana, caro):.2f}")
G = np.array([ana, beto, caro])                  # forma (3, 4)
normas = np.linalg.norm(G, axis=1, keepdims=True)  # forma (3, 1)
G_unit = G / normas                              # cada fila mide 1
print(np.linalg.norm(G_unit, axis=1))

similitudes = G_unit @ G_unit.T                  # (3, 4) @ (4, 3)
print(similitudes.shape)
print(similitudes.round(2))
T = np.array([[1.5, 0.5],
              [0.0, 1.0]])
angulos = np.linspace(0, 2 * np.pi, 100)
# 100 puntos de un círculo, uno por columna: forma (2, 100)
circulo = np.array([np.cos(angulos), np.sin(angulos)])
transformado = T @ circulo                       # (2, 2) @ (2, 100)

v = np.array([0.0, 1.0])
print("T @ v =", T @ v)

plt.plot(circulo[0], circulo[1], color="C2", label="Antes")
plt.plot(transformado[0], transformado[1], color="C0",
         label="Después de T")
plt.annotate("", xy=v, xytext=(0, 0),
             arrowprops=dict(arrowstyle="-|>", color="C2", lw=2))
plt.annotate("", xy=T @ v, xytext=(0, 0),
             arrowprops=dict(arrowstyle="-|>", color="C1", lw=2.5))
plt.text(0.5, 0.35, "T @ v")
plt.gca().set_aspect("equal")
plt.ylim(-1.2, 1.9)
plt.title("Una matriz transforma el plano")
# Muestras cortas para que la leyenda quepa dentro del eje
plt.legend(loc="upper center", ncol=2, handlelength=1.0,
           handletextpad=0.4, columnspacing=0.8)
plt.show()

Una imagen es un arreglo

from sklearn.datasets import load_sample_image

foto = load_sample_image("china.jpg")
print(type(foto).__name__, foto.shape, foto.dtype)
print("Píxel de arriba a la izquierda (R, G, B):", foto[0, 0])
print("Valor más chico y más grande:", foto.min(), foto.max())
recorte = foto[60:300, 150:470]      # filas 60 a 299, columnas 150 a 469
espejo = foto[:, ::-1]               # las columnas, de atrás para adelante
pesos = np.array([0.299, 0.587, 0.114])
gris = foto @ pesos                  # un número por píxel
print(recorte.shape, espejo.shape, gris.shape)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
ejes[0, 0].imshow(foto)
ejes[0, 0].set_title("Original")
ejes[0, 1].imshow(recorte)
ejes[0, 1].set_title("Recortada")
ejes[1, 0].imshow(espejo)
ejes[1, 0].set_title("Volteada")
ejes[1, 1].imshow(gris, cmap="gray")
ejes[1, 1].set_title("En gris")
for eje in ejes.flat:
    eje.axis("off")
plt.show()
lote = np.stack([foto, espejo, foto[::-1]])      # tres fotos
print("lote:", lote.shape)                        # (N, alto, ancho, 3)
print("una foto:", lote[1].shape)
print("el píxel (0, 0) de cada foto:", lote[:, 0, 0].shape)
# El color promedio de cada foto: se tachan el alto y el ancho
print(lote.mean(axis=(1, 2)).round(1))

Vectorizar: cambiar bucles por arreglos

import time

grande = rng.random(1_000_000)        # un millón de números entre 0 y 1

inicio = time.perf_counter()
total = 0.0
for valor in grande:
    total += valor * valor
t_bucle = time.perf_counter() - inicio

inicio = time.perf_counter()
total_np = np.sum(grande * grande)
t_numpy = time.perf_counter() - inicio

print("¿Dan lo mismo?", np.isclose(total, total_np))
print("¿NumPy fue más de 10 veces más rápido?", t_bucle > 10 * t_numpy)