Saltar al contenido

Indice / NumPy / 3.3

Indexación y rebanado

Sacar un pedazo de un array se parece a sacarlo de una lista, con una diferencia que cambia todo: el pedazo no es una copia, es una ventana al mismo dato.

Para qué sirve

Todo lo que viene después - máscaras, broadcasting, estadística por eje - empieza por seleccionar un pedazo. Y la distinción vista/copia es la causa directa del ChainedAssignmentError de pandas, que es el error que más confunde en la unidad 4.

ELI5Explicado facil

Rebanar una lista de Python es fotocopiar unas páginas: te llevás las copias y el original queda como estaba.

Rebanar un array es poner un marco sobre unas páginas del original. Lo que escribas dentro del marco, queda escrito en el libro.

La sintaxis

EjemploIndexar y rebanar, en una y dos dimensiones
import numpy as np

lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
                         skip_header=1, usecols=range(2, 26))
print("lecturas:", lecturas.shape, " (10 sensores, 24 horas)")

s01 = lecturas[0]
print()
print("EN UNA DIMENSION es igual que una lista de Python:")
print("   s01        :", np.round(s01[:8], 1), "...")
print("   s01[0]     :", s01[0])
print("   s01[-1]    :", s01[-1], "  <- el ultimo")
print("   s01[2:5]   :", s01[2:5], "  <- desde 2 INCLUIDO hasta 5 EXCLUIDO")
print("   s01[:3]    :", s01[:3])
print("   s01[20:]   :", s01[20:])
print("   s01[::6]   :", s01[::6], "  <- de a 6")
print("   s01[::-1]  :", np.round(s01[::-1][:4], 1), "... <- al reves")

print()
print("EN DOS DIMENSIONES se indexa con UNA coma, no con dos corchetes:")
print("   lecturas[0, 0]  ->", lecturas[0, 0], " (sensor 0, hora 0)")
print("   lecturas[0][0]  ->", lecturas[0][0], " (funciona, pero es mas lento:")
print("                        arma la fila entera y despues busca)")
print("   lecturas[4, 11] ->", lecturas[4, 11], " <- la lectura imposible")

print()
print("Fila entera, columna entera, y un rectangulo:")
print("   lecturas[2]        -> shape", lecturas[2].shape, " (el sensor S03)")
print("   lecturas[:, 0]     -> shape", lecturas[:, 0].shape,
      " (la hora h00 de los 10)")
print("   lecturas[:3, :4]   -> shape", lecturas[:3, :4].shape)
print(np.round(lecturas[:3, :4], 1))

print()
print("Los dos puntos solos significan 'todo este eje':")
print("   lecturas[:, 8:12] -> shape", lecturas[:, 8:12].shape,
      " (todas las filas, 4 horas)")
print("   lecturas[3:5, :]  -> shape", lecturas[3:5, :].shape,
      " (2 filas, todas las horas)")

print()
print("Y la diferencia que cuesta ver: un indice REDUCE la dimension,")
print("un rebanado NO.")
print("   lecturas[0]      -> shape", lecturas[0].shape, " (1 dimension)")
print("   lecturas[0:1]    -> shape", lecturas[0:1].shape, " (sigue en 2)")
print("   lecturas[:, 0]   -> shape", lecturas[:, 0].shape)
print("   lecturas[:, 0:1] -> shape", lecturas[:, 0:1].shape)
print("   Los dos tienen los mismos numeros. La diferencia importa cuando")
print("   despues hay que operar con otro array (leccion 3.5).")

print()
print("Ojo con el limite superior: NumPy lo excluye, como Python.")
print("   s01[0:3] son 3 elementos, no 4:", s01[0:3])
print("   pandas es la excepcion: loc['a':'c'] SI incluye 'c' (leccion 4.4)")
lecturas: (10, 24)  (10 sensores, 24 horas)

EN UNA DIMENSION es igual que una lista de Python:
   s01        : [20.8 19.8 19.2 19.  19.6 20.3 20.  21. ] ...
   s01[0]     : 20.8
   s01[-1]    : 21.1   <- el ultimo
   s01[2:5]   : [19.2 19.  19.6]   <- desde 2 INCLUIDO hasta 5 EXCLUIDO
   s01[:3]    : [20.8 19.8 19.2]
   s01[20:]   : [22.4 21.9 21.2 21.1]
   s01[::6]   : [20.8 20.  22.6 23.2]   <- de a 6
   s01[::-1]  : [21.1 21.2 21.9 22.4] ... <- al reves

EN DOS DIMENSIONES se indexa con UNA coma, no con dos corchetes:
   lecturas[0, 0]  -> 20.8  (sensor 0, hora 0)
   lecturas[0][0]  -> 20.8  (funciona, pero es mas lento:
                        arma la fila entera y despues busca)
   lecturas[4, 11] -> 999.9  <- la lectura imposible

Fila entera, columna entera, y un rectangulo:
   lecturas[2]        -> shape (24,)  (el sensor S03)
   lecturas[:, 0]     -> shape (10,)  (la hora h00 de los 10)
   lecturas[:3, :4]   -> shape (3, 4)
[[20.8 19.8 19.2 19. ]
 [18.5 17.1 16.6 16.1]
 [17.8 16.6 16.6 15.5]]

Los dos puntos solos significan 'todo este eje':
   lecturas[:, 8:12] -> shape (10, 4)  (todas las filas, 4 horas)
   lecturas[3:5, :]  -> shape (2, 24)  (2 filas, todas las horas)

Y la diferencia que cuesta ver: un indice REDUCE la dimension,
un rebanado NO.
   lecturas[0]      -> shape (24,)  (1 dimension)
   lecturas[0:1]    -> shape (1, 24)  (sigue en 2)
   lecturas[:, 0]   -> shape (10,)
   lecturas[:, 0:1] -> shape (10, 1)
   Los dos tienen los mismos numeros. La diferencia importa cuando
   despues hay que operar con otro array (leccion 3.5).

Ojo con el limite superior: NumPy lo excluye, como Python.
   s01[0:3] son 3 elementos, no 4: [20.8 19.8 19.2]
   pandas es la excepcion: loc['a':'c'] SI incluye 'c' (leccion 4.4)
Sobre una grilla (10, 24)
ExpresiónShapeQué es
a[0, 0]escalarUna celda. Una coma, no dos corchetes
a[0](24,)La fila 0 entera
a[:, 0](10,)La columna 0 entera
a[:3, :4](3, 4)Un rectángulo
a[3:5, :](2, 24)Dos filas completas
a[::-1](10, 24)Las filas al revés
Un índice reduce la dimensión; un rebanado, no

a[0] tiene shape (24,) y a[0:1] tiene (1, 24). Los mismos números, distinta forma.

Parece una sutileza y deja de serlo en la lección 3.5: (10, 1) y (10,) se comportan distinto al combinarse con otro array.

El límite superior se excluye

a[0:3] son tres elementos, no cuatro - igual que en las listas de Python. La excepción del curso es loc de pandas, que sí incluye el final; es el tema de la lección 4.4 y sorprende a todo el mundo.

Vistas y copias

EjemploVistas y copias: el rebanado NO copia
import numpy as np

original = np.arange(10)
print("original:", original)

rebanada = original[2:6]
print("rebanada = original[2:6] ->", rebanada)
print("¿es una vista?", rebanada.base is not None)

rebanada[0] = 999
print()
print("toque rebanada[0] = 999")
print("   rebanada:", rebanada)
print("   original:", original, "  <- CAMBIO TAMBIEN")

print()
print("En una lista de Python esto no pasa: el slice copia.")
lista = list(range(10))
trozo = lista[2:6]
trozo[0] = 999
print("   lista :", lista, "  <- intacta")
print("   trozo :", trozo)
print()
print("Es la diferencia mas importante entre las dos, y la que produce los")
print("bugs mas dificiles de encontrar: el cambio aparece en otro lado.")

print()
print("Como saber si un array es dueno de sus datos:")
copia = original[2:6].copy()
for nombre, array in (("original", original), ("rebanada", rebanada),
                      ("copia", copia)):
    print(f"   {nombre:<9} .base is None -> {array.base is None}"
          f"   {'(dueno)' if array.base is None else '(vista)'}")

print()
print("QUE DEVUELVE UNA VISTA y que devuelve una copia:")
print(f"   {'a[2:6]':<24} vista")
print(f"   {'a[:, 0]':<24} vista")
print(f"   {'a.reshape(2, 5)':<24} vista")
print(f"   {'a.T':<24} vista")
print(f"   {'a.ravel()':<24} vista si se puede")
print(f"   {'a.flatten()':<24} COPIA, siempre")
print(f"   {'a.copy()':<24} COPIA, siempre")
print(f"   {'a[[0, 2, 4]]':<24} COPIA (indexado por lista)")
print(f"   {'a[a > 3]':<24} COPIA (indexado booleano)")
print(f"   {'a.astype(float)':<24} COPIA, siempre")

grilla = np.arange(12).reshape(3, 4)
print()
print("Se comprueba en dos lineas:")
print("   grilla[:, 1].base is None      ->", grilla[:, 1].base is None)
print("   grilla[grilla > 5].base is None ->", grilla[grilla > 5].base is None)

print()
print("La regla practica: si vas a MODIFICAR, pedi .copy() explicito.")
print("Cuesta una linea y evita el bug que aparece tres funciones despues.")
print("En pandas la misma idea produce el ChainedAssignmentError de la")
print("leccion 4.7, y por el mismo motivo.")
original: [0 1 2 3 4 5 6 7 8 9]
rebanada = original[2:6] -> [2 3 4 5]
¿es una vista? True

toque rebanada[0] = 999
   rebanada: [999   3   4   5]
   original: [  0   1 999   3   4   5   6   7   8   9]   <- CAMBIO TAMBIEN

En una lista de Python esto no pasa: el slice copia.
   lista : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]   <- intacta
   trozo : [999, 3, 4, 5]

Es la diferencia mas importante entre las dos, y la que produce los
bugs mas dificiles de encontrar: el cambio aparece en otro lado.

Como saber si un array es dueno de sus datos:
   original  .base is None -> True   (dueno)
   rebanada  .base is None -> False   (vista)
   copia     .base is None -> True   (dueno)

QUE DEVUELVE UNA VISTA y que devuelve una copia:
   a[2:6]                   vista
   a[:, 0]                  vista
   a.reshape(2, 5)          vista
   a.T                      vista
   a.ravel()                vista si se puede
   a.flatten()              COPIA, siempre
   a.copy()                 COPIA, siempre
   a[[0, 2, 4]]             COPIA (indexado por lista)
   a[a > 3]                 COPIA (indexado booleano)
   a.astype(float)          COPIA, siempre

Se comprueba en dos lineas:
   grilla[:, 1].base is None      -> False
   grilla[grilla > 5].base is None -> True

La regla practica: si vas a MODIFICAR, pedi .copy() explicito.
Cuesta una linea y evita el bug que aparece tres funciones despues.
En pandas la misma idea produce el ChainedAssignmentError de la
leccion 4.7, y por el mismo motivo.

Es la diferencia con una lista de Python, y produce los bugs más difíciles de encontrar, porque el cambio aparece en otro lado:

Devuelve una vista (comparte memoria)Devuelve una copia
a[2:6]a.copy()
a[:, 0]a.flatten()
a.reshape(2, 5)a[[0, 2, 4]] - indexado por lista
a.Ta[a > 3] - indexado booleano
a.ravel(), si puedea.astype(float)

Se comprueba en una línea: a.base is None es True si el array es dueño de sus datos, y False si es una vista.

La regla práctica

Si vas a modificar, pedí .copy() explícito. Cuesta una línea y evita el bug que aparece tres funciones después.

Que NumPy no copie es una decisión de rendimiento: rebanar un array de un millón de elementos no debería costar un millón de escrituras. La contrapartida es que hay que saberlo.

Asignar, y el indexado por lista

EjemploAsignar sobre un rebanado, y el indexado por lista
import numpy as np

lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
                         skip_header=1, usecols=range(2, 26)).copy()

print("Se puede asignar a un rebanado entero, no solo a una celda:")
prueba = np.zeros((3, 4))
print(prueba)
prueba[1] = 5
print()
print("prueba[1] = 5 ->")
print(prueba)
prueba[:, 0] = [1, 2, 3]
print()
print("prueba[:, 0] = [1, 2, 3] ->")
print(prueba)
print()
print("El lado derecho tiene que ENTRAR en la forma del izquierdo, y si no:")
try:
    prueba[:, 0] = [1, 2]
except ValueError as error:
    print("   prueba[:, 0] = [1, 2] ->", type(error).__name__ + ":", error)

print()
print("=" * 62)
print("El indexado por LISTA de posiciones (fancy indexing)")
print("=" * 62)
s01 = lecturas[0]
print("   s01[[0, 6, 12, 18]] ->", s01[[0, 6, 12, 18]])
print("   Toma esas cuatro posiciones, en ESE orden, y devuelve una COPIA.")
print("   s01[[18, 0]]        ->", s01[[18, 0]], " <- el orden lo elegis vos")
print("   s01[[0, 0, 0]]      ->", s01[[0, 0, 0]], " <- se puede repetir")

print()
print("En dos dimensiones toma los PARES, no el rectangulo:")
print("   lecturas[[0, 1], [5, 6]] ->", lecturas[[0, 1], [5, 6]])
print("   que es [lecturas[0, 5], lecturas[1, 6]], dos numeros.")
print("   Para el rectangulo hay que separarlo en dos pasos:")
print("   lecturas[[0, 1]][:, [5, 6]] ->")
print(lecturas[[0, 1]][:, [5, 6]])
print("   O usar np.ix_, que arma la grilla:")
print("   lecturas[np.ix_([0, 1], [5, 6])] ->")
print(lecturas[np.ix_([0, 1], [5, 6])])

print()
print("Como devuelve una copia, asignarle NO modifica el original...")
copia = s01[[0, 1, 2]]
copia[0] = -1
print("   toque copia[0] = -1 -> s01[0] sigue en", s01[0])
print("...pero asignar DIRECTAMENTE sobre el indexado, si:")
s01[[0, 1, 2]] = -1
print("   s01[[0, 1, 2]] = -1 -> s01[:4] =", s01[:4])
print("   La diferencia: en el segundo caso NumPy nunca crea la copia,")
print("   escribe en el original. Es la misma distincion que en pandas")
print("   entre df[mask]['col'] = 0 y df.loc[mask, 'col'] = 0.")

print()
print("Y el ultimo, el mas util de todos: ordenar por otra cosa.")
# Se relee el archivo: arriba le escribimos -1 a las tres primeras horas
# de S01, y ese promedio ya no seria el del archivo.
intactas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
                         skip_header=1, usecols=range(2, 26))
promedios = np.nanmean(intactas, axis=1)
orden = np.argsort(promedios)
print("   promedios     :", np.round(promedios, 1))
print("   np.argsort()  :", orden, " <- las POSICIONES ordenadas")
print("   promedios[orden]:", np.round(promedios[orden], 1))
print("   Y con esas posiciones se reordena cualquier otro array:")
nombres = np.array([f"S{i:02d}" for i in range(1, 11)])
print("   nombres[orden]:", nombres[orden])
Se puede asignar a un rebanado entero, no solo a una celda:
[[0. 0. 0. 0.]
 [0. 0. 0. 0.]
 [0. 0. 0. 0.]]

prueba[1] = 5 ->
[[0. 0. 0. 0.]
 [5. 5. 5. 5.]
 [0. 0. 0. 0.]]

prueba[:, 0] = [1, 2, 3] ->
[[1. 0. 0. 0.]
 [2. 5. 5. 5.]
 [3. 0. 0. 0.]]

El lado derecho tiene que ENTRAR en la forma del izquierdo, y si no:
   prueba[:, 0] = [1, 2] -> ValueError: could not broadcast input array from shape (2,) into shape (3,)

==============================================================
El indexado por LISTA de posiciones (fancy indexing)
==============================================================
   s01[[0, 6, 12, 18]] -> [20.8 20.  22.6 23.2]
   Toma esas cuatro posiciones, en ESE orden, y devuelve una COPIA.
   s01[[18, 0]]        -> [23.2 20.8]  <- el orden lo elegis vos
   s01[[0, 0, 0]]      -> [20.8 20.8 20.8]  <- se puede repetir

En dos dimensiones toma los PARES, no el rectangulo:
   lecturas[[0, 1], [5, 6]] -> [20.3 18.3]
   que es [lecturas[0, 5], lecturas[1, 6]], dos numeros.
   Para el rectangulo hay que separarlo en dos pasos:
   lecturas[[0, 1]][:, [5, 6]] ->
[[20.3 20. ]
 [17.  18.3]]
   O usar np.ix_, que arma la grilla:
   lecturas[np.ix_([0, 1], [5, 6])] ->
[[20.3 20. ]
 [17.  18.3]]

Como devuelve una copia, asignarle NO modifica el original...
   toque copia[0] = -1 -> s01[0] sigue en 20.8
...pero asignar DIRECTAMENTE sobre el indexado, si:
   s01[[0, 1, 2]] = -1 -> s01[:4] = [-1. -1. -1. 19.]
   La diferencia: en el segundo caso NumPy nunca crea la copia,
   escribe en el original. Es la misma distincion que en pandas
   entre df[mask]['col'] = 0 y df.loc[mask, 'col'] = 0.

Y el ultimo, el mas util de todos: ordenar por otra cosa.
   promedios     : [21.7 19.6 18.7 21.6 60.6 21.1 24.5 20.  22.8 17.5]
   np.argsort()  : [9 2 1 7 5 3 0 8 6 4]  <- las POSICIONES ordenadas
   promedios[orden]: [17.5 18.7 19.6 20.  21.1 21.6 21.7 22.8 24.5 60.6]
   Y con esas posiciones se reordena cualquier otro array:
   nombres[orden]: ['S10' 'S03' 'S02' 'S08' 'S06' 'S04' 'S01' 'S09' 'S07' 'S05']

Se puede asignar a una fila entera, a una columna entera o a un rectángulo, y el lado derecho tiene que entrar en la forma del izquierdo. El indexado por lista de posiciones agrega tres cosas que un rebanado no puede hacer:

a[[0, 1], [5, 6]] son dos números, no un rectángulo

Toma los pares: a[0, 5] y a[1, 6]. Para el rectángulo hacen falta dos pasos (a[[0, 1]][:, [5, 6]]) o np.ix_([0, 1], [5, 6]), que arma la grilla.

Leer devuelve copia; asignar escribe en el original

copia = a[[0, 1, 2]] y después copia[0] = -1 no toca a. Pero a[[0, 1, 2]] = -1 sí, porque ahí NumPy nunca crea la copia intermedia.

Es exactamente la distinción de pandas entre df[mask]["col"] = 0 (que no hace nada) y df.loc[mask, "col"] = 0 (que funciona).

argsort: ordenar por otra cosa

Es el uso más productivo del indexado por lista. np.argsort(promedios) no devuelve los promedios ordenados: devuelve las posiciones en el orden correcto. Y con esas posiciones se reordena cualquier otro array del mismo largo.

orden = np.argsort(promedios)
promedios[orden]   # los promedios, de menor a mayor
nombres[orden]     # los nombres de los sensores, en ESE mismo orden

Sobre la grilla real, nombres[orden] da ['S10', 'S03', 'S02', …, 'S07', 'S05']: S05 queda último, con su 60,65 producto de la lectura imposible.

Dónde se rompe

IndexError: index 24 is out of bounds for axis 1 with size 24

Causa: los índices van de 0 a 23. El mensaje dice el eje y el tamaño.

Arreglo: a[-1] para el último. Un rebanado fuera de rango no falla: a[100:200] devuelve un array vacío en silencio.

IndexError: too many indices for array: array is 1-dimensional, but 2 were indexed

Causa: escribiste a[0, 0] sobre un array de una dimensión.

Arreglo: print(a.shape). Suele pasar después de un a[0] que ya redujo la dimensión.

ValueError: could not broadcast input array from shape (2,) into shape (3,)

Causa: el lado derecho de la asignación no entra en la forma del izquierdo.

Arreglo: o le pasás tantos valores como posiciones, o le pasás uno solo y NumPy lo repite.

(sin error) modifiqué una variable y cambió otra

Causa: las dos son vistas del mismo array.

Arreglo: .base is None para confirmarlo, y .copy() para separarlas.

(sin error) asigné sobre el resultado de un filtro y no pasó nada

Causa: el indexado booleano y el indexado por lista devuelven copias: escribiste en un array temporal.

Arreglo: asignar directamente sobre el indexado (a[a > 3] = 0), no sobre una variable intermedia.

Recetario

Selección
ExpresiónQué hace¿Copia?
a[i, j]Una celda-
a[i] / a[:, j]Fila / columnaVista
a[i0:i1, j0:j1]RectánguloVista
a[::paso]De a saltosVista
a[::-1]Al revésVista
a[[0, 2, 4]]Posiciones sueltas, en ese ordenCopia
a[np.ix_(filas, cols)]El rectángulo de esas filas y columnasCopia
a[i] = valorAsigna a toda la filaEscribe en el original
a.copy()Copia independienteCopia
a.base is None¿Es dueño de sus datos?-
np.argsort(v)Las posiciones en orden-
otro[np.argsort(v)]Reordena otro por vCopia

Ponete a prueba

1. Sobre una grilla (10, 24), ¿qué devuelve a[:, 0]?Nivel 1 · Recordar
2. ¿Cuántos elementos tiene a[2:6]?Nivel 1 · Recordar
3. b = a[2:6] y después b[0] = 999. ¿Qué le pasa a a?Nivel 2 · Predecir la salida
4. ¿Cuál de estas cuatro expresiones devuelve una copia?Nivel 2 · Predecir la salida
5. Querés poner en 0 todas las lecturas mayores que 50. Las cuatro corren: ¿cuál conviene?Nivel 3 · Elegir entre dos casi correctas
6. Tenés promedios (10 valores) y nombres (10 sensores). ¿Cómo obtenés los nombres ordenados por promedio?Nivel 3 · Elegir entre dos casi correctas
7. Este código quiere reemplazar los valores imposibles por NaN. Corre sin error, imprime 1 valores corregidos, y el array queda igual. ¿Por qué?
sospechosos = a[a > 50]
print(sospechosos.size, "valores corregidos")
sospechosos[:] = np.nan
Nivel 4 · Encontrar el defecto