NumPy: pensar en arreglos

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

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: filtros de imagen
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables de la teoría: np, plt, rng, notas, materias, ana, beto, caro, G_unit, parecido, foto, recorte, pesos y lote. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta: en este tema conviene escribir primero la forma que esperas y recién después comprobarla con .shape.

Ejercicios

1. Predecir formas

Sin computadora, escribe la forma del resultado de cada línea, o «error» si NumPy no puede calcularla. Usa a = np.zeros((2, 3, 4)).

  1. np.arange(24).reshape(4, -1)
  2. a.T
  3. a.sum(axis=1)
  4. a.mean(axis=(0, 2))
  5. a[0] y a[:, 0]
  6. np.ones((5, 1)) + np.ones(3)
  7. np.ones((8, 1, 6)) * np.ones((7, 1))
  8. np.ones((2, 3)) @ np.ones((3, 5))
  9. np.ones((2, 3)) + np.ones((3, 2))
Respuesta
a = np.zeros((2, 3, 4))
print("1:", np.arange(24).reshape(4, -1).shape)
print("2:", a.T.shape)
print("3:", a.sum(axis=1).shape)
print("4:", a.mean(axis=(0, 2)).shape)
print("5:", a[0].shape, a[:, 0].shape)
print("6:", (np.ones((5, 1)) + np.ones(3)).shape)
print("7:", (np.ones((8, 1, 6)) * np.ones((7, 1))).shape)
print("8:", (np.ones((2, 3)) @ np.ones((3, 5))).shape)
1: (4, 6)
2: (4, 3, 2)
3: (2, 4)
4: (3,)
5: (3, 4) (2, 4)
6: (5, 3)
7: (8, 7, 6)
8: (2, 5)
  1. 24 / 4 = 6, así que el -1 vale 6.
  2. .T invierte el orden de todos los ejes.
  3. y 4. Se tachan los ejes pedidos: el 3 en el primer caso; el 2 y el 4 en el segundo.
  4. a[0] fija el eje 0 y deja los otros dos; a[:, 0] fija el eje 1.
  5. Alineadas por la derecha: (5, 1) y (1, 3). El 1 de cada lado se estira: (5, 3).
  6. (7, 1) pasa a (1, 7, 1). De derecha a izquierda: 6 y 1 dan 6; 1 y 7 dan 7; 8 y 1 dan 8.
  7. Las medidas de adentro coinciden (3 y 3) y quedan las de afuera: (2, 5).
  8. Error. La última dimensión es 3 frente a 2: ni iguales ni con un 1.
np.ones((2, 3)) + np.ones((3, 2))
Traceback (most recent call last):
  ...
ValueError: operands could not be broadcast together with shapes (2,3) (3,2) 

2. Sacar partes de un tensor

lote tiene forma (3, 427, 640, 3): tres fotos, cada una de alto × ancho × canales. Escribe una sola expresión para cada pedido y anota la forma que esperas antes de comprobarla.

  1. La tercera foto del lote.
  2. El canal verde (índice 1) de todas las fotos.
  3. El cuadrado de 100 × 100 píxeles de la esquina inferior derecha de cada foto, con sus tres canales.
  4. La primera foto achicada: una de cada cuatro filas y una de cada cuatro columnas.
  5. ¿Qué fracción de los píxeles de foto tiene más azul que rojo? (Pista: el promedio de un arreglo de True y False es la fracción de True.)
  6. Crea con np.arange y reshape un arreglo de forma (2, 3, 4, 5) con los números del 0 en adelante. ¿Qué número está en la posición [1, 2, 3, 4]? ¿Por qué?
Respuesta
print("1:", lote[2].shape)
print("2:", lote[:, :, :, 1].shape)
print("3:", lote[:, -100:, -100:].shape)
print("4:", lote[0, ::4, ::4].shape)

mas_azul = foto[:, :, 2] > foto[:, :, 0]
print(f"5: {mas_azul.mean():.2f} de los píxeles")

t = np.arange(2 * 3 * 4 * 5).reshape(2, 3, 4, 5)
print("6:", t.shape, t[1, 2, 3, 4], t.size)
1: (427, 640, 3)
2: (3, 427, 640)
3: (3, 100, 100, 3)
4: (107, 160, 3)
5: 0.52 de los píxeles
6: (2, 3, 4, 5) 119 120
  • En el 3, -100: toma las últimas 100 filas y las últimas 100 columnas; el canal, que no se menciona, queda entero.
  • En el 4, 427 filas de cuatro en cuatro dan 107, porque se incluye la fila 0.
  • En el 5, algo más de la mitad de los píxeles (0,52) tiene más azul que rojo: el cielo y el agua ocupan buena parte de la foto.
  • En el 6, [1, 2, 3, 4] es la última posición de cada eje, así que es el último número: 119, uno menos que los 120 que hay.

3. Parecido y matrices

  1. Calcula a mano el producto punto de [1, 2, 3] y [4, 5, 6], y compruébalo con @.
  2. Dani puntúa así fútbol, música, lectura y videojuegos: [1, 4, 5, 0]. Normaliza su vector y calcula, con una sola multiplicación de matriz por vector, su parecido con Ana, Beto y Caro (usa G_unit). ¿A quién se parece más? ¿Qué forma tiene el resultado y por qué?
  3. Eli le pone 5 a todo: [5, 5, 5, 5]. Compara el producto punto de Ana con Beto y de Ana con Eli. Después compara sus parecidos. ¿Cuál de las dos medidas te dice mejor quién comparte los gustos de Ana?
Respuesta
  1. 1·4 + 2·5 + 3·6 = 4 + 10 + 18 = 32.
print("1:", np.array([1, 2, 3]) @ np.array([4, 5, 6]))

dani = np.array([1, 4, 5, 0])
dani_unit = dani / np.linalg.norm(dani)
print("2:", G_unit.shape, "@", dani_unit.shape)
print("   Ana, Beto, Caro:", (G_unit @ dani_unit).round(2))

eli = np.array([5, 5, 5, 5])
print("3: Ana · Beto =", ana @ beto, "  Ana · Eli =", ana @ eli)
print(f"   parecido Ana-Beto: {parecido(ana, beto):.2f}"
      f"   Ana-Eli: {parecido(ana, eli):.2f}")
1: 32
2: (3, 4) @ (4,)
   Ana, Beto, Caro: [0.21 0.21 0.95]
3: Ana · Beto = 40   Ana · Eli = 50
   parecido Ana-Beto: 0.95   Ana-Eli: 0.77
  1. (3, 4) @ (4,) da (3,): un parecido por cada fila de G_unit. Dani se parece mucho a Caro y poco a Ana y a Beto.
  2. Ana · Eli es más grande que Ana · Beto, solo porque Eli pone números altos a todo. El parecido corrige eso: Beto comparte los gustos de Ana mucho más que Eli. Para comparar gustos sirve el parecido, no el producto punto solo.

4. Tres bucles para vectorizar

Escribe cada bucle en una sola línea de NumPy, sin for, y comprueba que da lo mismo. Después mide con time.perf_counter() cuántas veces más rápida es tu versión del bucle 3.

valores = rng.normal(60, 15, size=10_000)

# Bucle 1: cada valor al cuadrado, más 1
cuadrados = []
for v in valores:
    cuadrados.append(v ** 2 + 1)

# Bucle 2: cuántos valores superan el promedio
promedio = sum(valores) / len(valores)
cuenta = 0
for v in valores:
    if v > promedio:
        cuenta += 1

# Bucle 3: pasar una imagen a gris, píxel por píxel
def gris_con_bucle(imagen):
    alto, ancho, _ = imagen.shape
    resultado = np.zeros((alto, ancho))
    for i in range(alto):
        for j in range(ancho):
            r, g, b = imagen[i, j]
            resultado[i, j] = 0.299 * r + 0.587 * g + 0.114 * b
    return resultado
Respuesta
cuadrados_np = valores ** 2 + 1
cuenta_np = (valores > valores.mean()).sum()
print("1:", np.allclose(cuadrados, cuadrados_np))
print("2:", cuenta, cuenta_np)

inicio = time.perf_counter()
gris_b = gris_con_bucle(recorte)
t_bucle = time.perf_counter() - inicio

inicio = time.perf_counter()
gris_v = recorte @ pesos
t_vector = time.perf_counter() - inicio

print("3:", np.allclose(gris_b, gris_v), gris_v.shape)
print("¿Más de 50 veces más rápido?", t_bucle > 50 * t_vector)
1: True
2: 4969 4969
3: True (240, 320)
¿Más de 50 veces más rápido? True

El bucle 3 recorre los 76 800 píxeles del recorte (240 × 320) de a uno. En la computadora donde se escribió este tema tardó algo más de una décima de segundo, y la versión vectorizada, una o dos décimas de milisegundo: entre 600 y 1200 veces menos, según la medición. Tus números serán otros, pero la diferencia va a ser de cientos de veces. En Colab puedes compararlos con %timeit gris_con_bucle(recorte) y %timeit recorte @ pesos.

5. Distancias entre todos los pares

Cuatro puntos del plano están en las filas de P = np.array([[0, 0], [3, 4], [6, 0], [3, -4]]). Calcula la matriz de 4 × 4 con la distancia euclidiana entre cada par de puntos, sin bucles. La distancia entre (x₁, y₁) y (x₂, y₂) es √((x₁ − x₂)² + (y₁ − y₂)²).

Pista: P[:, np.newaxis, :] tiene forma (4, 1, 2) y P[np.newaxis, :, :] tiene forma (1, 4, 2). ¿Qué forma tiene su resta?

Respuesta
P = np.array([[0, 0], [3, 4], [6, 0], [3, -4]])
diferencias = P[:, np.newaxis, :] - P[np.newaxis, :, :]
print("Diferencias:", diferencias.shape)
distancias = np.sqrt((diferencias ** 2).sum(axis=-1))
print(distancias)

# Comprobación con dos bucles
con_bucle = np.zeros((4, 4))
for i in range(4):
    for j in range(4):
        con_bucle[i, j] = np.linalg.norm(P[i] - P[j])
print("¿Iguales?", np.allclose(distancias, con_bucle))
Diferencias: (4, 4, 2)
[[0. 5. 6. 5.]
 [5. 0. 5. 8.]
 [6. 5. 0. 5.]
 [5. 8. 5. 0.]]
¿Iguales? True

Por broadcasting, (4, 1, 2) - (1, 4, 2) da (4, 4, 2): para cada par (i, j), la diferencia entre el punto i y el punto j. Sumar los cuadrados en el último eje (axis=-1 es «el último») lo tacha y deja (4, 4). La matriz tiene ceros en la diagonal (cada punto está a distancia 0 de sí mismo) y es simétrica, porque de i a j hay lo mismo que de j a i. Esta misma cuenta es el corazón de los vecinos cercanos (tema 8) y del clustering (tema 14).

6. Escalar por materia y por estudiante

  1. Escala las notas por materia: en cada columna, la nota más baja tiene que quedar en 0 y la más alta en 1. Usa (notas - mínimo) / (máximo - mínimo), con los mínimos y máximos de cada columna.
  2. Haz lo mismo por estudiante: en cada fila, su peor nota queda en 0 y la mejor en 1. Si te sale un ValueError, escribe las formas una debajo de la otra y arréglalo.
Respuesta
minimo = notas.min(axis=0)                    # forma (4,)
maximo = notas.max(axis=0)
por_materia = (notas - minimo) / (maximo - minimo)
print("1:", minimo.shape, por_materia.shape)
print(por_materia[:2].round(2))

minimo = notas.min(axis=1, keepdims=True)     # forma (6, 1)
maximo = notas.max(axis=1, keepdims=True)
por_estudiante = (notas - minimo) / (maximo - minimo)
print("2:", minimo.shape, por_estudiante.shape)
print(por_estudiante[:2].round(2))
1: (4,) (6, 4)
[[0.6  0.39 0.77 0.  ]
 [0.12 0.05 0.35 0.4 ]]
2: (6, 1) (6, 4)
[[0.64 0.32 1.   0.  ]
 [0.   0.2  0.68 1.  ]]

Por materia no hace falta nada especial: (6, 4) con (4,) estira el vector hacia abajo. Por estudiante, notas.min(axis=1) da (6,), que choca con las 4 columnas; con keepdims=True es (6, 1) y se estira hacia la derecha. Comprueba que cada columna del primer resultado y cada fila del segundo tiene un 0 y un 1.

Mini-proyecto: filtros de imagen

Programa cuatro filtros que reciban una foto (un arreglo de forma (alto, ancho, 3) y tipo uint8) y devuelvan otra foto de tipo uint8, solo con NumPy y sin bucles. Pruébalos con foto y muestra los cuatro resultados en una cuadrícula de 2 × 2 con plt.subplots(2, 2, figsize=(4.8, 4.8)), como en la teoría.

  1. Brillo. Suma una cantidad a todos los números. Antes, mira qué da foto[0, 0] + 100 y explica por qué. Para evitarlo, pasa la foto a enteros grandes con .astype(int), suma, limita con np.clip(..., 0, 255) y vuelve a uint8 con .astype(np.uint8).
  2. Negativo. Cada número v pasa a 255 − v: lo oscuro se vuelve claro y cada color, su opuesto.
  3. Espejo. La mitad izquierda de la foto queda igual y la mitad derecha es su reflejo, como si hubiera un espejo vertical en el centro. (Pista: foto.copy() y asignar a una rebanada.)
  4. Pixelado. Divide la foto en bloques de 16 × 16 píxeles y pinta cada bloque con su color promedio. Recorta primero la foto para que el alto y el ancho sean múltiplos de 16. Pista: con reshape, un arreglo de (416, 640, 3) se puede ver como (26, 16, 40, 16, 3); ¿qué ejes tienes que promediar? Para volver al tamaño original, usa np.repeat(chica, 16, axis=0) y lo mismo con axis=1.

Para ir más allá: haz que tus filtros funcionen también con todo lote a la vez, un arreglo de cuatro dimensiones. ¿Cuáles funcionan sin cambios? ¿Qué hay que cambiar en el pixelado?

Una solución de referencia, para comparar con la tuya
print("foto[0, 0] + 100 =", foto[0, 0] + 100, "  foto[0, 0] =", foto[0, 0])

def brillo(img, cantidad):
    return np.clip(img.astype(int) + cantidad, 0, 255).astype(np.uint8)

def negativo(img):
    return 255 - img

def espejo(img):
    resultado = img.copy()
    mitad = img.shape[1] // 2
    # La mitad derecha es la izquierda leída de atrás para adelante
    resultado[:, mitad:] = img[:, :mitad][:, ::-1]
    return resultado

def pixelado(img, lado=16):
    alto = img.shape[0] // lado * lado
    ancho = img.shape[1] // lado * lado
    bloques = img[:alto, :ancho].reshape(alto // lado, lado,
                                         ancho // lado, lado, 3)
    # El promedio de cada bloque: se tachan los ejes de adentro
    chica = bloques.mean(axis=(1, 3))
    grande = np.repeat(np.repeat(chica, lado, axis=0), lado, axis=1)
    return grande.astype(np.uint8)

filtros = [("Más brillo", brillo(foto, 70)), ("Negativo", negativo(foto)),
           ("Espejo", espejo(foto)), ("Pixelado", pixelado(foto))]
for nombre, img in filtros:
    print(f"{nombre:<10} {img.shape} {img.dtype}")

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for eje, (nombre, img) in zip(ejes.flat, filtros):
    eje.imshow(img)
    eje.set_title(nombre)
    eje.axis("off")
plt.show()
foto[0, 0] + 100 = [18 45 75]   foto[0, 0] = [174 201 231]
Más brillo (427, 640, 3) uint8
Negativo   (427, 640, 3) uint8
Espejo     (427, 640, 3) uint8
Pixelado   (416, 640, 3) uint8

Cuatro versiones de la foto del templo: más clara, en negativo con los colores invertidos, con la mitad derecha reflejando la izquierda y pixelada en bloques de color de 16 × 16 píxeles.

  • foto[0, 0] + 100 da números más chicos que los originales: un uint8 solo llega hasta 255 y, al pasarse, da la vuelta y vuelve a empezar desde 0 (por ejemplo, 174 + 100 = 274, que se guarda como 274 − 256 = 18). NumPy no avisa. Por eso el brillo pasa primero a int.
  • El pixelado pierde las últimas 11 filas (427 no es múltiplo de 16): la foto queda de 416 × 640.
  • Con lote, el brillo y el negativo funcionan sin cambios: operan con cada número, sin importar la forma. En el espejo y el pixelado, el ancho pasa a ser el eje 2 y el alto el eje 1, así que hay que agregar un : al principio de cada rebanada, un eje más (el de las N fotos) al principio del reshape y sumar 1 a los ejes del mean y de los repeat.

Errores comunes

  • Confundir el eje. axis=0 no es «por fila»: recorre las filas y deja un resultado por columna. Ante la duda, piensa qué eje desaparece de la forma.
  • El desborde de uint8. Sumar o restar a una imagen sin pasarla antes a int o float da la vuelta al pasar de 255 o de 0, sin ningún aviso.
  • Creer que una rebanada es una copia. parte = notas[0] y después parte[0] = 100 también cambia notas. Usa .copy() cuando quieras un arreglo independiente.
  • Broadcasting que no da error, pero sí un resultado equivocado. (6,) con (6, 1) no falla: da una matriz de (6, 6). Si el resultado tiene una forma que no esperabas, revisa las formas antes de seguir.
  • Usar * cuando querías @. a * b multiplica elemento a elemento; a @ b es el producto punto o el producto de matrices.
  • Usar reshape para transponer. b.reshape(4, 3) no es lo mismo que b.T: reshape vuelve a acomodar los números en el mismo orden; .T cambia filas por columnas.
  • Medir el tiempo una sola vez. Una medición suelta puede salir rara porque la computadora estaba ocupada en otra cosa. %timeit repite muchas veces por eso.
  • Volver a los bucles por costumbre. Si estás recorriendo un arreglo con for para hacer la misma cuenta en cada elemento, casi siempre hay una versión vectorizada.

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Crear, indexar y rebanar arreglos de 1 a 4 dimensiones (ejercicio 2 y el mini-proyecto).
  • Predecir la forma del resultado de reshape, transpose, sum(axis=…) y del broadcasting (ejercicios 1 y 6).
  • Multiplicar matrices y explicar el producto punto como «parecido» entre vectores (ejercicio 3).
  • Reemplazar un bucle por una operación vectorizada y medir cuánto se acelera (ejercicios 4 y 5).