Indice / NumPy / 3.1
El array, y por qué no una lista
Un ndarray es una lista con dos promesas: todos los
elementos son del mismo tipo, y la forma se conoce de antemano. Todo lo que NumPy hace
rápido sale de esas dos promesas.
Para qué sirve
NumPy es la base sobre la que está construido pandas: una columna de un
DataFrame es un array. Aprenderlo primero significa que en la
unidad 4 la mitad de las cosas ya se van a entender - el dtype, las
máscaras booleanas, el NaN que contamina.
Y para el trabajo de esta materia, NumPy resuelve directamente lo que se hace sobre una grilla de números: estadística por eje, normalización, detección de atípicos.
ELI5Explicado facil
Una lista de Python es un cajón con etiquetas: cada elemento vive donde quiera y la lista guarda una anotación de dónde está. Por eso puede mezclar números, texto y otras listas.
Un array es una fila de casilleros iguales, todos del mismo tamaño y uno pegado al otro. No admite mezclas, y a cambio la computadora puede recorrerlos sin ir a buscar nada.
Los cinco atributos
import numpy as np
notas = np.array([9.7, 4.7, 8.6, 6.0, 3.8])
print("notas :", notas)
print(" dtype :", notas.dtype, " <- el tipo de TODOS sus elementos")
print(" shape :", notas.shape, " <- 5 elementos en 1 dimension")
print(" ndim :", notas.ndim)
print(" size :", notas.size)
print(" itemsize :", notas.itemsize, "bytes por elemento")
print(" nbytes :", notas.nbytes, "bytes en total")
print()
grilla = np.array([[9.7, 4.7, 8.6],
[6.0, 3.8, 4.2],
[8.1, 3.6, 3.8]])
print("una grilla de 3x3:")
print(grilla)
print(" shape:", grilla.shape, "| ndim:", grilla.ndim, "| size:", grilla.size)
print(" shape es (filas, columnas), y ese orden no cambia nunca.")
print()
print("La homogeneidad no es opcional: si le das tipos mezclados, NumPy")
print("elige uno solo para todos y convierte. Se llama upcasting.")
for lista in ([1, 2, 3], [1, 2.5, 3], [1, 2, "tres"], [True, False, 1]):
a = np.array(lista)
print(f" {str(lista):<22} -> dtype {str(a.dtype):<8} {a}")
print()
print("El tercero es el peligroso: un solo texto convirtio los numeros en")
print("texto. El dtype <U21 significa 'cadena de hasta 21 caracteres'.")
mezclado = np.array([1, 2, "tres"])
print(" dtype:", mezclado.dtype, "| el 1 ahora es", repr(mezclado[0]))
try:
mezclado.sum()
except TypeError as error:
print(" .sum() ->", type(error).__name__)
print(" NumPy 2 se niega a sumar cadenas, y eso es una suerte: avisa.")
print(" pandas, sobre la misma columna, las CONCATENA sin quejarse.")
print(" Los dos casos vienen del mismo origen: un solo valor de texto")
print(" en una columna numerica, que es el defecto mas comun del corpus.")
print()
print("Y el dtype se puede pedir explicitamente, que es lo que conviene")
print("cuando ya sabes que estas leyendo:")
enteros = np.array([1, 2, 3], dtype=np.int64)
flotantes = np.array([1, 2, 3], dtype=np.float64)
print(" dtype=int64 ->", enteros, enteros.dtype)
print(" dtype=float64 ->", flotantes, flotantes.dtype)
notas : [9.7 4.7 8.6 6. 3.8]
dtype : float64 <- el tipo de TODOS sus elementos
shape : (5,) <- 5 elementos en 1 dimension
ndim : 1
size : 5
itemsize : 8 bytes por elemento
nbytes : 40 bytes en total
una grilla de 3x3:
[[9.7 4.7 8.6]
[6. 3.8 4.2]
[8.1 3.6 3.8]]
shape: (3, 3) | ndim: 2 | size: 9
shape es (filas, columnas), y ese orden no cambia nunca.
La homogeneidad no es opcional: si le das tipos mezclados, NumPy
elige uno solo para todos y convierte. Se llama upcasting.
[1, 2, 3] -> dtype int64 [1 2 3]
[1, 2.5, 3] -> dtype float64 [1. 2.5 3. ]
[1, 2, 'tres'] -> dtype <U21 ['1' '2' 'tres']
[True, False, 1] -> dtype int64 [1 0 1]
El tercero es el peligroso: un solo texto convirtio los numeros en
texto. El dtype <U21 significa 'cadena de hasta 21 caracteres'.
dtype: <U21 | el 1 ahora es np.str_('1')
.sum() -> TypeError
NumPy 2 se niega a sumar cadenas, y eso es una suerte: avisa.
pandas, sobre la misma columna, las CONCATENA sin quejarse.
Los dos casos vienen del mismo origen: un solo valor de texto
en una columna numerica, que es el defecto mas comun del corpus.
Y el dtype se puede pedir explicitamente, que es lo que conviene
cuando ya sabes que estas leyendo:
dtype=int64 -> [1 2 3] int64
dtype=float64 -> [1. 2. 3.] float64
| Atributo | Qué dice | Ejemplo |
|---|---|---|
dtype | El tipo de todos sus elementos | float64, int64, <U21 |
shape | La forma, como tupla (filas, columnas) | (5,), (10, 24) |
ndim | Cuántas dimensiones | 1, 2 |
size | Cuántos elementos en total | 240 para (10, 24) |
nbytes | Cuánta memoria ocupa | size × itemsize |
np.array([1, 2, "tres"]) tiene dtype <U21: «cadena de
hasta 21 caracteres». El 1 pasó a ser '1'. Se llama
upcasting, y es silencioso.
Después, .sum() sobre ese array levanta TypeError -
NumPy 2 se niega a sumar cadenas, y eso es una suerte porque avisa. pandas,
sobre la misma columna, las concatena y devuelve '12tres'. Los
dos casos vienen del mismo origen: un valor de texto en una columna numérica, que es el
defecto más común del corpus.
Contra una lista
import sys
import timeit
import numpy as np
N = 100_000
lista = list(range(N))
arreglo = np.arange(N, dtype=np.int64)
print("MEMORIA, para", f"{N:,}".replace(",", ".") , "enteros:")
print(" lista de Python:", sys.getsizeof(lista), "bytes solo los punteros")
print(" ...mas cada objeto int, que pesa", sys.getsizeof(0), "bytes aparte")
print(" array de NumPy :", arreglo.nbytes, "bytes, y eso es todo")
print(" El array guarda los numeros pegados en memoria; la lista guarda")
print(" direcciones a objetos que estan cada uno por su lado.")
print()
print("VELOCIDAD, sumando 1 a cada elemento:")
con_lista = timeit.timeit(lambda: [x + 1 for x in lista], number=20) / 20
con_array = timeit.timeit(lambda: arreglo + 1, number=20) / 20
# Los milisegundos exactos cambian en cada corrida y en cada maquina,
# asi que esta pagina publica la comparacion, no el cronometro.
print(" [x + 1 for x in lista] : el bucle de Python")
print(" arreglo + 1 : una sola operacion de NumPy")
print(" ¿el array tarda menos de la decima parte?",
con_array * 10 < con_lista)
print(" En esta maquina la diferencia es de uno a dos ordenes de")
print(" magnitud, y crece con el tamano: el bucle paga por elemento,")
print(" el array no.")
print()
print("Y la diferencia que mas se nota al escribir codigo:")
print(" lista * 2 ->", [1, 2, 3] * 2, " <- repite la lista")
print(" array * 2 ->", np.array([1, 2, 3]) * 2, " <- duplica cada valor")
print(" lista + 1 -> TypeError")
try:
[1, 2, 3] + 1
except TypeError as error:
print(" ", error)
print(" array + 1 ->", np.array([1, 2, 3]) + 1)
print()
print("Esa es la idea entera de NumPy: la operacion se escribe una vez y se")
print("aplica a todos los elementos. Se llama vectorizacion, y es la")
print("leccion 3.2.")
print()
print("Lo que se pierde a cambio:")
print(" - el array tiene UN tipo: no se pueden mezclar numeros y texto")
print(" - el tamano es fijo: no hay append barato")
print(" - se puede indexar y rebanar, pero no insertar en el medio")
print("Para una tabla de datos, esas tres perdidas no molestan nunca.")
MEMORIA, para 100.000 enteros:
lista de Python: 800056 bytes solo los punteros
...mas cada objeto int, que pesa 28 bytes aparte
array de NumPy : 800000 bytes, y eso es todo
El array guarda los numeros pegados en memoria; la lista guarda
direcciones a objetos que estan cada uno por su lado.
VELOCIDAD, sumando 1 a cada elemento:
[x + 1 for x in lista] : el bucle de Python
arreglo + 1 : una sola operacion de NumPy
¿el array tarda menos de la decima parte? True
En esta maquina la diferencia es de uno a dos ordenes de
magnitud, y crece con el tamano: el bucle paga por elemento,
el array no.
Y la diferencia que mas se nota al escribir codigo:
lista * 2 -> [1, 2, 3, 1, 2, 3] <- repite la lista
array * 2 -> [2 4 6] <- duplica cada valor
lista + 1 -> TypeError
can only concatenate list (not "int") to list
array + 1 -> [2 3 4]
Esa es la idea entera de NumPy: la operacion se escribe una vez y se
aplica a todos los elementos. Se llama vectorizacion, y es la
leccion 3.2.
Lo que se pierde a cambio:
- el array tiene UN tipo: no se pueden mezclar numeros y texto
- el tamano es fijo: no hay append barato
- se puede indexar y rebanar, pero no insertar en el medio
Para una tabla de datos, esas tres perdidas no molestan nunca.
list | ndarray | |
|---|---|---|
| Tipos | Cualquiera, mezclados | Uno solo |
| Memoria (100.000 enteros) | 800 KB de punteros más 28 bytes por objeto | 800 KB y nada más |
| Sumar 1 a todos | Un bucle, que paga por elemento | Una operación, uno o dos órdenes de magnitud más rápida |
x * 2 | Repite la lista | Duplica cada valor |
x + 1 | TypeError | Suma 1 a cada valor |
| Tamaño | Crece con append | Fijo |
Las tres cosas que se pierden - mezclar tipos, crecer barato, insertar en el
medio - no hacen falta nunca para una tabla de datos. Lo que se gana,
arreglo + 1 en vez de un bucle, es la idea entera de NumPy y el tema de la
lección 3.2.
Convertir
import warnings
import numpy as np
notas = np.array([9.7, 4.7, 8.6, 6.0, 3.8])
print("astype convierte y devuelve un array NUEVO:")
enteros = notas.astype(np.int64)
print(" original:", notas, notas.dtype)
print(" astype :", enteros, enteros.dtype)
print(" TRUNCA, no redondea: 9.7 -> 9, no 10.")
print(" Si queres redondear: np.round(notas).astype(int) ->",
np.round(notas).astype(np.int64))
print()
print("Y no toca el original:", notas)
print()
print("El caso que hay que conocer: un NaN convertido a entero.")
con_hueco = np.array([9.7, np.nan, 8.6])
print(" con_hueco:", con_hueco, con_hueco.dtype)
with warnings.catch_warnings(record=True) as avisos:
warnings.simplefilter("always")
convertido = con_hueco.astype(np.int64)
print(" .astype(int64) ->", convertido)
print(" avisos:", [a.category.__name__ for a in avisos])
print()
print(" NO levanta excepcion: emite un RuntimeWarning y devuelve")
print(" -9223372036854775808, que es el entero mas chico que existe.")
print(" Ese numero entra al calculo como si fuera un dato.")
print()
print(" pandas, sobre lo mismo, se niega:")
print(" IntCastingNaNError: Cannot convert non-finite values to integer")
print(" NaN es un float y solo existe en float. En pandas hay un tipo")
print(" entero que admite ausentes (Int64, con I mayuscula); en NumPy")
print(" puro no: o hay float, o no hay huecos.")
print()
print("El otro caso que muerde: el desborde, que NO siempre avisa.")
print(" np.int8 admite de -128 a 127.")
try:
np.array([200], dtype=np.int8)
except OverflowError as error:
print(" crear el array con 200 ->", type(error).__name__, " <- avisa")
grandes = np.array([200, 300])
print(" pero .astype(np.int8) NO avisa:",
grandes, "->", grandes.astype(np.int8))
al_borde = np.array([127], dtype=np.int8)
print(" y sumar tampoco:", al_borde, "+ 1 ->", al_borde + np.int8(1))
print(" Da la vuelta y sigue. Por eso los tipos chicos se usan solo")
print(" cuando la memoria importa de verdad y se sabe el rango.")
print()
print("Y el redondeo de NumPy no es el que se aprende en la escuela:")
medios = np.array([0.5, 1.5, 2.5, 3.5, 4.5])
print(" np.round([0.5 1.5 2.5 3.5 4.5]) ->", np.round(medios))
print(" Redondea al PAR mas cercano, no siempre hacia arriba. Es el")
print(" estandar IEEE 754 y lo mismo hace round() de Python.")
print(" round(2.5) de Python ->", round(2.5))
print(" Va a volver en la leccion 7.4, cuando dos archivos correctos")
print(" difieran en un centavo.")
print()
print("Resumen de lo que hay que mirar antes de un astype:")
print(" ¿hay NaN? -> a entero, no se puede")
print(" ¿hay decimales? -> a entero, se truncan")
print(" ¿el tipo entra? -> int8 no aguanta 200")
print(" ¿es una copia? -> si, siempre: astype no modifica")
astype convierte y devuelve un array NUEVO:
original: [9.7 4.7 8.6 6. 3.8] float64
astype : [9 4 8 6 3] int64
TRUNCA, no redondea: 9.7 -> 9, no 10.
Si queres redondear: np.round(notas).astype(int) -> [10 5 9 6 4]
Y no toca el original: [9.7 4.7 8.6 6. 3.8]
El caso que hay que conocer: un NaN convertido a entero.
con_hueco: [9.7 nan 8.6] float64
.astype(int64) -> [ 9 -9223372036854775808 8]
avisos: ['RuntimeWarning']
NO levanta excepcion: emite un RuntimeWarning y devuelve
-9223372036854775808, que es el entero mas chico que existe.
Ese numero entra al calculo como si fuera un dato.
pandas, sobre lo mismo, se niega:
IntCastingNaNError: Cannot convert non-finite values to integer
NaN es un float y solo existe en float. En pandas hay un tipo
entero que admite ausentes (Int64, con I mayuscula); en NumPy
puro no: o hay float, o no hay huecos.
El otro caso que muerde: el desborde, que NO siempre avisa.
np.int8 admite de -128 a 127.
crear el array con 200 -> OverflowError <- avisa
pero .astype(np.int8) NO avisa: [200 300] -> [-56 44]
y sumar tampoco: [127] + 1 -> [-128]
Da la vuelta y sigue. Por eso los tipos chicos se usan solo
cuando la memoria importa de verdad y se sabe el rango.
Y el redondeo de NumPy no es el que se aprende en la escuela:
np.round([0.5 1.5 2.5 3.5 4.5]) -> [0. 2. 2. 4. 4.]
Redondea al PAR mas cercano, no siempre hacia arriba. Es el
estandar IEEE 754 y lo mismo hace round() de Python.
round(2.5) de Python -> 2
Va a volver en la leccion 7.4, cuando dos archivos correctos
difieran en un centavo.
Resumen de lo que hay que mirar antes de un astype:
¿hay NaN? -> a entero, no se puede
¿hay decimales? -> a entero, se truncan
¿el tipo entra? -> int8 no aguanta 200
¿es una copia? -> si, siempre: astype no modifica
| Pregunta | Qué pasa si no |
|---|---|
| ¿Hay decimales? | A entero se truncan, no se redondean: 9.7 → 9 |
¿Hay NaN? | No levanta excepción. Un RuntimeWarning y el valor queda en -9223372036854775808 |
| ¿El tipo entra? | [200, 300].astype(int8) da [-56, 44], sin avisar |
| ¿Modifica el original? | No: astype siempre devuelve una copia |
NaN convertido a entero es el peor de los cuatro
NumPy devuelve -9223372036854775808 - el entero más chico que
existe - y ese número entra al cálculo como si fuera un dato. pandas, sobre lo
mismo, se niega con IntCastingNaNError.
La regla: NaN solo existe en float. Si
hace falta una columna entera con huecos, pandas tiene Int64 (con I
mayúscula); NumPy puro no tiene equivalente.
np.round([0.5, 1.5, 2.5, 3.5, 4.5]) da [0, 2, 2, 4, 4]:
redondea al par más cercano, no siempre hacia arriba. Es el estándar
IEEE 754, y round() de Python hace lo mismo (round(2.5) es
2).
Va a volver en la lección 7.4, cuando dos archivos correctos difieran en un centavo.
Crear y leer
import numpy as np
print("Las cinco formas de crear un array sin escribir los valores:")
print(" np.zeros(5) ->", np.zeros(5))
print(" np.ones(3) ->", np.ones(3))
print(" np.full(4, -1) ->", np.full(4, -1))
print(" np.arange(0, 10, 2) ->", np.arange(0, 10, 2))
print(" np.linspace(0, 1, 5) ->", np.linspace(0, 1, 5))
print()
print("arange va de a pasos y NO incluye el final, como range().")
print("linspace va por cantidad de puntos y SI incluye el final.")
print(" np.arange(0, 1, 0.25) ->", np.arange(0, 1, 0.25))
print(" np.linspace(0, 1, 5) ->", np.linspace(0, 1, 5))
print()
print("Y en dos dimensiones se le pasa la forma como tupla:")
print(" np.zeros((2, 3)) ->")
print(np.zeros((2, 3)))
print()
print("=" * 62)
print("Ahora el archivo real: numpy_sensores.csv")
print("=" * 62)
# genfromtxt lee texto a un array. skip_header saltea la cabecera y
# usecols elige las 24 columnas horarias, que son las numericas.
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
with open("numpy_sensores.csv", encoding="utf-8") as f:
cabecera = f.readline().strip().split(",")
sensores = np.genfromtxt("numpy_sensores.csv", delimiter=",", skip_header=1,
usecols=(0,), dtype=str)
print("shape :", lecturas.shape, " <- 10 sensores x 24 horas")
print("dtype :", lecturas.dtype)
print("size :", lecturas.size, "lecturas")
print("horas :", cabecera[2], "...", cabecera[-1])
print("sensores:", sensores)
print()
print("primeras 4 horas de los 3 primeros sensores:")
print(lecturas[:3, :4])
print()
print("Las celdas vacias del archivo llegaron como NaN, sin pedirlo:")
huecos = np.isnan(lecturas)
print(" cuantos NaN:", int(huecos.sum()))
filas, columnas = np.nonzero(huecos)
for fila, columna in zip(filas, columnas):
print(f" {sensores[fila]} {cabecera[columna + 2]}")
print()
print("Y ojo con el maximo:")
print(" lecturas.max() ->", lecturas.max(), " <- un solo NaN contamina")
print(" np.nanmax() ->", np.nanmax(lecturas))
posicion = np.nanargmax(lecturas)
print(" ...y ese maximo esta en", sensores[posicion // 24],
cabecera[posicion % 24 + 2])
print()
print("El 999.9 sigue ahi porque es un numero perfectamente valido: ni")
print("genfromtxt ni el dtype lo detectan. Solo lo encuentra un chequeo")
print("de rango, que es codigo que hay que escribir (leccion 3.7).")
print("Y lo del NaN que arruina la reduccion es la leccion 3.6.")
Las cinco formas de crear un array sin escribir los valores:
np.zeros(5) -> [0. 0. 0. 0. 0.]
np.ones(3) -> [1. 1. 1.]
np.full(4, -1) -> [-1 -1 -1 -1]
np.arange(0, 10, 2) -> [0 2 4 6 8]
np.linspace(0, 1, 5) -> [0. 0.25 0.5 0.75 1. ]
arange va de a pasos y NO incluye el final, como range().
linspace va por cantidad de puntos y SI incluye el final.
np.arange(0, 1, 0.25) -> [0. 0.25 0.5 0.75]
np.linspace(0, 1, 5) -> [0. 0.25 0.5 0.75 1. ]
Y en dos dimensiones se le pasa la forma como tupla:
np.zeros((2, 3)) ->
[[0. 0. 0.]
[0. 0. 0.]]
==============================================================
Ahora el archivo real: numpy_sensores.csv
==============================================================
shape : (10, 24) <- 10 sensores x 24 horas
dtype : float64
size : 240 lecturas
horas : h00 ... h23
sensores: ['S01' 'S02' 'S03' 'S04' 'S05' 'S06' 'S07' 'S08' 'S09' 'S10']
primeras 4 horas de los 3 primeros sensores:
[[20.8 19.8 19.2 19. ]
[18.5 17.1 16.6 16.1]
[17.8 16.6 16.6 15.5]]
Las celdas vacias del archivo llegaron como NaN, sin pedirlo:
cuantos NaN: 4
S03 h07
S03 h08
S06 h13
S09 h21
Y ojo con el maximo:
lecturas.max() -> nan <- un solo NaN contamina
np.nanmax() -> 999.9
...y ese maximo esta en S05 h11
El 999.9 sigue ahi porque es un numero perfectamente valido: ni
genfromtxt ni el dtype lo detectan. Solo lo encuentra un chequeo
de rango, que es codigo que hay que escribir (leccion 3.7).
Y lo del NaN que arruina la reduccion es la leccion 3.6.
| Expresión | Devuelve | Ojo con |
|---|---|---|
np.zeros(5) | Cinco ceros float | El dtype por defecto es float64 |
np.zeros((2, 3)) | Una grilla 2×3 | La forma va como tupla |
np.ones(3), np.full(4, -1) | Rellenos | full toma el dtype del valor |
np.arange(0, 10, 2) | De a pasos | No incluye el final, como range |
np.linspace(0, 1, 5) | Por cantidad de puntos | Sí incluye el final |
np.genfromtxt(ruta, delimiter=",") | Un array desde un CSV | Las celdas vacías llegan como NaN sin pedirlo |
Sobre numpy_sensores.csv, genfromtxt con
skip_header=1 y usecols=range(2, 26) devuelve una grilla
(10, 24) de float64: 10 sensores por 24 horas. Las 4 celdas
vacías del archivo llegan como NaN, y el 999.9 llega como
999.9, porque es un número perfectamente válido.
numpy_sensores.csv
Dónde se rompe
Causa: genfromtxt intentó leer una columna de texto como
número.
Arreglo: usecols= para elegir solo las numéricas, o
dtype=str si querés el texto.
dtype <U21
Causa: uno de los valores era texto y arrastró a todos.
Arreglo: print(a.dtype) apenas se crea. Si dice
<U algo, hay un valor no numérico adentro.
Causa: .sum() sobre un array de cadenas.
Arreglo: el problema no es la suma, es el dtype: hay que
convertir antes, decidiendo qué hacer con lo que no convierte.
Causa: astype(int) sobre un array con NaN.
Arreglo: filtrar los NaN primero, o quedarse en
float. Es un warning: si no lo mirás, el programa sigue con
un número basura adentro.
Causa: las filas que le pasaste no tienen todas el mismo largo.
Arreglo: un array 2D es rectangular. Si las filas son irregulares, no es un array: es una lista de listas.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
np.array(lista) | Crea el array e infiere el dtype | Desde datos que ya tenés |
np.array(lista, dtype=np.float64) | Fuerza el tipo | Cuando sabés lo que estás leyendo |
a.shape / a.dtype | Forma y tipo | Lo primero, siempre |
a.astype(np.int64) | Copia convertida | Con las cuatro precauciones de arriba |
np.round(a).astype(int) | Redondear y convertir | Cuando truncar no es lo que querés |
np.arange(inicio, fin, paso) | Secuencia por pasos | Índices, rangos regulares |
np.linspace(inicio, fin, cuantos) | Secuencia por cantidad | Cuando el final tiene que estar |
np.zeros((f, c)) | Grilla de ceros | Reservar antes de llenar |
np.genfromtxt(r, delimiter=",", skip_header=1) | CSV → array | Grillas numéricas |
np.isnan(a) | Máscara de ausentes | Antes de cualquier cuenta |
np.nonzero(mascara) | Las posiciones que son True | Para saber dónde están |