La lección anterior terminó señalando al culpable: las listas de Python no saben de matemáticas. [12.50, 9.95] * 2 no duplica los precios — repite la lista. Para sumar el IVA a 487 importes necesitas un bucle o una comprensión, y para un millón de importes ese bucle se nota. NumPy (Numerical Python) resuelve ambas cosas con una sola estructura, el ndarray: un bloque compacto de números del mismo tipo sobre el que las operaciones se escriben sin bucle y se ejecutan en C. Es la pieza más abajo del ecosistema PyData — pandas, Matplotlib y scikit-learn están construidos sobre ella — así que entender NumPy es entender qué hacen los demás por dentro.

Contenido

  1. Por qué las listas no bastan
  2. El ndarray: np.array, dtype, shape, ndim
  3. Vectorización: operar sin bucles
  4. Crear arrays: zeros, ones, arange, linspace
  5. Indexación, slicing y la idea estrella: boolean masking
  6. Agregaciones: sum, mean, max, argmax, std
  7. Arrays 2D y el parámetro axis
  8. Broadcasting básico
  9. Lista vs array: cuándo cada uno

Por qué las listas no bastan

Instala y prueba (dentro del venv, como siempre):

pip install numpy
precios = [12.50, 9.95, 15.90, 21.00]   # Odisea, Hamlet, Quijote, Fausto

print(precios * 2)        # ¿duplicar precios? No: repetir la lista
# [12.5, 9.95, 15.9, 21.0, 12.5, 9.95, 15.9, 21.0]

# print(precios * 1.04)   # TypeError: can't multiply sequence by non-int

La lista es un contenedor genérico: puede mezclar floats, cadenas y dataclasses Libro, y por eso cada elemento es un objeto Python completo con su sobrecoste. Dos consecuencias:

  • Expresividad: las operaciones matemáticas elemento a elemento no existen; hay que escribir el bucle (o la comprensión de M2) cada vez.
  • Rendimiento: el bucle de Python interpreta cada iteración; con muchos datos, la diferencia frente a código compilado es de uno a dos órdenes de magnitud.

El ndarray

import numpy as np    # el alias 'np' es convención universal

precios = np.array([12.50, 9.95, 15.90, 21.00])

print(precios)          # [12.5   9.95 15.9  21.  ]
print(type(precios))    # <class 'numpy.ndarray'>
print(precios.dtype)    # float64  -> TODOS los elementos son de este tipo
print(precios.shape)    # (4,)     -> tupla con el tamaño de cada dimensión
print(precios.ndim)     # 1        -> número de dimensiones

Tres atributos que consultarás constantemente:

Atributo Qué dice Ejemplo
dtype Tipo único de todos los elementos (float64, int64, bool...) float64
shape Tamaño por dimensión, como tupla (4,) vector, (4, 6) matriz
ndim Número de dimensiones 1 vector, 2 tabla

El precio de esa homogeneidad: si mezclas tipos, NumPy convierte todo al más general (np.array([1, 2.5])float64; si hay una cadena, todo acaba siendo texto y adiós matemáticas). Esa restricción es justo lo que le permite ser compacto y rápido.

Vectorización: operar sin bucles

La operación se aplica elemento a elemento, sin que escribas el bucle. Compara las tres formas de añadir el IVA del 4% a los precios:

precios_lista = [12.50, 9.95, 15.90, 21.00]
IVA_LIBROS = 0.04

# 1) Bucle clásico (M2)
con_iva = []
for p in precios_lista:
    con_iva.append(round(p * (1 + IVA_LIBROS), 2))

# 2) Comprensión (M2)
con_iva = [round(p * (1 + IVA_LIBROS), 2) for p in precios_lista]

# 3) NumPy: la operación ES la expresión
precios = np.array(precios_lista)
con_iva = np.round(precios * (1 + IVA_LIBROS), 2)
print(con_iva)    # [13.   10.35 16.54 21.84]

Las tres dan lo mismo; la tercera no solo es más corta, es más rápida (el bucle ocurre en C) y se lee como matemáticas: "precios por 1.04". Lo mismo con dos arrays:

unidades = np.array([154, 121, 182, 63])    # vendidas ene-jun (11-01)
ingresos = precios * unidades                # elemento a elemento
print(ingresos)         # [1925.   1203.95 2893.8  1323.  ]
print(ingresos.sum())   # 7345.75  -> facturación total a PVP

Y la tarifa de socio del curso entero (IVA del 4 % y descuento del 5 %, la fórmula de M3), ahora en una línea:

IVA_LIBROS, DESCUENTO_SOCIO = 0.04, 0.05
precios_socio = np.round(precios * (1 + IVA_LIBROS) * (1 - DESCUENTO_SOCIO), 2)
print(precios_socio)    # [12.35  9.83 15.71 20.75]  <- los de M3, clavados

Crear arrays

Función Qué crea Ejemplo
np.zeros(n) n ceros (acumuladores) np.zeros(7) → contador por día de semana
np.ones(n) n unos np.ones(4)
np.arange(a, b, paso) Como range, pero array (admite floats) np.arange(1, 7) → meses [1 2 3 4 5 6]
np.linspace(a, b, n) n puntos equiespaciados incluyendo ambos extremos np.linspace(0, 1, 5)[0. 0.25 0.5 0.75 1.]
meses = np.arange(1, 7)
print(meses)                       # [1 2 3 4 5 6]
print(np.zeros(3))                 # [0. 0. 0.]
print(np.linspace(9.95, 21.0, 3))  # [ 9.95  15.475 21.   ]

Indexación, slicing y boolean masking

Índices y rebanadas funcionan como en las listas (M4):

importes = np.array([12.50, 19.90, 15.71, 21.00, 31.80, 47.70, 25.00])

print(importes[0])      # 12.5
print(importes[-1])     # 25.0
print(importes[1:4])    # [19.9  15.71 21.  ]

Y ahora la idea estrella del ecosistema entero, la que reaparecerá idéntica en pandas: comparar un array produce un array de booleanos, y ese array sirve de filtro.

grandes = importes > 15.00
print(grandes)              # [False  True  True  True  True  True  True]
print(importes[grandes])    # [19.9  15.71 21.   31.8  47.7  25.  ]

# En una línea, como se escribe en la práctica:
print(importes[importes > 15.00])

# Combinando condiciones: & (y), | (o), ~ (no) — con paréntesis obligatorios
print(importes[(importes > 15) & (importes < 30)])   # [19.9  15.71 21.   25.  ]

Léelo despacio: importes > 15.00 no es un if, es una operación vectorizada que devuelve (7,) booleanos; usarlo entre corchetes selecciona los elementos donde hay True. Es el filter/comprensión-con-if de M2 convertido en álgebra. Un pariente útil: np.where(condición, si_true, si_false) aplica un "if vectorizado" (np.where(es_socio, precio * 0.95, precio)).

Agregaciones

print(importes.sum())      # 173.61
print(importes.mean())     # 24.80142857142857
print(importes.max())      # 47.7
print(importes.argmax())   # 5  -> el ÍNDICE del máximo, no el valor
print(importes.std())      # 11.29... desviación típica: cuánto se dispersan

argmax merece atención: devuelve dónde está el máximo, lo que permite responder "¿qué título factura más?" cruzando arrays paralelos:

titulos = np.array(["La Odisea", "Hamlet", "El Quijote", "Fausto"])
print(titulos[ingresos.argmax()])    # El Quijote  (2893.80 €)

Y combinando máscara + agregación, preguntas enteras en una línea: importes[importes > 15].mean() — "importe medio de las ventas grandes".

Arrays 2D y el parámetro axis

Las ventas por título y por mes forman una tabla: 4 filas (títulos) × 6 columnas (enero-junio). Conceptualmente, las unidades de ventas_2026.csv reorganizadas:

# filas: Odisea, Hamlet, Quijote, Fausto | columnas: ene..jun
unidades_2d = np.array([
    [18, 17, 21, 50, 25, 23],    # La Odisea
    [15, 13, 17, 39, 19, 18],    # Hamlet
    [22, 20, 25, 59, 29, 27],    # El Quijote
    [ 7,  8,  8, 20, 11,  9],    # Fausto
])
print(unidades_2d.shape)   # (4, 6)
print(unidades_2d.ndim)    # 2
print(unidades_2d[2, 3])   # 59 -> El Quijote en abril (fila 2, columna 3)
print(unidades_2d[:, 3])   # [50 39 59 20] -> TODOS los títulos en abril

axis indica a lo largo de qué dimensión se agrega — la dimensión que desaparece:

print(unidades_2d.sum())          # 520 -> total del semestre
print(unidades_2d.sum(axis=0))    # [ 62  58  71 168  84  77] -> por MES (colapsa filas)
print(unidades_2d.sum(axis=1))    # [154 121 182  63]         -> por TÍTULO (colapsa columnas)

Ahí está, en números, el gancho de M10: abril (168) más que duplica a cualquier otro mes. Sant Jordi existe y los arrays lo ven. La regla mnemotécnica: axis=0 agrega "hacia abajo" (resultado por columna), axis=1 "hacia la derecha" (resultado por fila).

Broadcasting básico

Broadcasting es la regla por la que NumPy opera arrays de formas distintas "estirando" el pequeño. Dos casos cubren el 90% del uso:

# 1) Escalar contra array: el escalar se aplica a todo (ya lo usaste: precios * 1.04)
print(unidades_2d * 2)

# 2) Vector contra matriz: el vector (6,) se aplica a CADA fila de la (4, 6)
peso_mes = np.array([1.0, 1.0, 1.0, 0.5, 1.0, 1.0])   # abril "descontado" al comparar
print((unidades_2d * peso_mes).sum(axis=1))
# [129.  101.5 152.5  53. ] -> ventas "desestacionalizadas" por título

La regla formal (las formas se comparan de derecha a izquierda y deben coincidir o ser 1) da para mucho más, pero con escalar-contra-array y vector-contra-matriz tienes lo necesario para este curso.

Lista vs array: cuándo cada uno

Criterio list np.ndarray
Tipos mezclados (Libro, cadenas, floats) Sí — su gran virtud No: un solo dtype
Crecer elemento a elemento (append) Barata y natural Cara (recrea el array): construye la lista y convierte al final
Matemáticas elemento a elemento Bucle/comprensión manual Una expresión, ejecutada en C
Filtrar por condición Comprensión con if Máscara booleana
Miles/millones de números Lenta y pesada en memoria Su hábitat natural
El catálogo de Papyrus (objetos ricos) list[Libro] de M5, correcto No aplica

La honestidad prometida: en el día a día de Papyrus no escribirás casi NumPy puro — usarás pandas, que le pone nombres a filas y columnas y lee CSV solo. Pero pandas es NumPy por debajo: cada columna de un DataFrame es un array, las máscaras booleanas de esta lección se escriben idénticas allí, y axis, dtype y las agregaciones reaparecen con los mismos nombres. Lo aprendido aquí no se tira: se hereda.

Errores Comunes y Consejos

  • and/or en máscaras. (a > 1) and (a < 5) lanza ValueError: The truth value of an array is ambiguous. Con arrays se usa &, |, ~ — y siempre con paréntesis, porque & tiene más precedencia que >.
  • Olvidar que el slice es una vista. b = a[1:4]; b[0] = 99 modifica también a (a diferencia de las listas de M4, cuyo slicing copia). Si necesitas independencia: a[1:4].copy().
  • dtype silenciosamente entero. np.array([12, 9, 15]) es int64; una división posterior funciona (produce floats), pero asignar a[0] = 12.5 truncaría a 12. Si trabajas con precios, parte de floats: np.array([12.0, ...]).
  • Confundir max y argmax. Uno devuelve el valor, el otro el índice. Para "¿qué título vende más?" necesitas argmax + el array de títulos.
  • Hacer append en bucle a un array. np.append recrea el array entero cada vez. Acumula en una list y haz np.array(lista) al final — o mejor, deja que pandas.read_csv lo haga todo en 11-03.

Ejercicios

  1. Con precios = np.array([12.50, 9.95, 15.90, 21.00]) y unidades = np.array([154, 121, 182, 63]): calcula en una expresión cada uno los ingresos por título a tarifa de socio (IVA 4 % + descuento 5 %, redondeo a 2 decimales del precio antes de multiplicar) y el título con menos ingresos a PVP.
  2. Con importes = np.array([12.50, 19.90, 15.71, 21.00, 31.80, 47.70, 25.00, 9.95, 12.35]): (a) ¿cuántas ventas superan la media? (b) ¿qué porcentaje del importe total aportan? Pistas: una máscara puede sumarse (True vale 1) y usarse dos veces.
  3. Con la matriz unidades_2d de la lección: obtén la media mensual de cada título excluyendo abril (para no dejar que Sant Jordi infle el "mes normal"), usando slicing o una máscara sobre las columnas. ¿Qué título tiene el mes normal más alto?

Soluciones

  1. ingresos_socio = np.round(precios * 1.04 * 0.95, 2) * unidades
    print(ingresos_socio)                  # [1901.9  1189.43 2859.22 1307.25]
    titulos = np.array(["La Odisea", "Hamlet", "El Quijote", "Fausto"])
    print(titulos[(precios * unidades).argmin()])   # Hamlet (1203.95 € a PVP)
    
    Cuidado con el matiz del enunciado: se redondea la tarifa de socio (12.35, 9.83...) y luego se multiplica — como cobra Papyrus — no al revés.
  2. media = importes.mean()                       # 21.77...
    mascara = importes > media
    print(mascara.sum())                          # 3 ventas (31.80, 47.70, 25.00)
    print(importes[mascara].sum() / importes.sum() * 100)   # 53.2...%
    
    Tres ventas de nueve aportan más de la mitad del importe: otra vez la asimetría de 11-01. mascara.sum() cuenta Trues porque los booleanos son 0/1 — un idioma NumPy que verás constantemente.
  3. sin_abril = np.delete(unidades_2d, 3, axis=1)       # o unidades_2d[:, [0,1,2,4,5]]
    medias = sin_abril.mean(axis=1)
    print(np.round(medias, 1))    # [20.8 16.4 24.6  8.6]
    titulos = np.array(["La Odisea", "Hamlet", "El Quijote", "Fausto"])
    print(titulos[medias.argmax()])    # El Quijote
    
    axis=1 porque quieres colapsar los meses (columnas) y quedarte con un valor por título (fila). El Quijote lidera también el mes "normal": su liderazgo no es solo cosa de Sant Jordi.

Conclusión

NumPy te ha dado el motor: el ndarray homogéneo y compacto, la vectorización que convierte bucles en expresiones (precios * 1.04), las máscaras booleanas que convierten filtros en álgebra (importes[importes > 15]), las agregaciones con axis que hicieron visible el pico de Sant Jordi (abril: 168 unidades), y el broadcasting que estira escalares y vectores sin copiarlos. También te ha enseñado su límite para Papyrus: los arrays son números anónimos — la fila 2 "es" El Quijote solo porque tú lo recuerdas, y meter las 487 filas del CSV en arrays paralelos a mano es trabajo de picapedrero. La próxima lección presenta pandas, que envuelve estos arrays en tablas con nombres: columnas etiquetadas, read_csv que carga ventas_2026.csv en una línea (el csv.DictReader de M6 con esteroides, como prometimos), y groupby para responder por fin, con datos y de una vez, las preguntas de Ana.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados