En 07-01 decidimos que el taller de Marta se monta sobre Python y comprobamos con un benchmark que su velocidad viene de delegar el cálculo en librerías nativas. Las tres primeras de esas librerías, las que sostienen todo lo demás, son las que en el módulo 4 utilizamos sin detenernos: NumPy, que aporta el array numérico y las operaciones vectorizadas; pandas, que aporta la tabla con nombres de columna, fechas y agrupaciones; y Matplotlib, que dibuja. generar_pedidos_ml era NumPy más pandas; groupby("categoria")["devuelto"].mean() era pandas; las curvas de aprendizaje de 04-06 eran Matplotlib. Esta lección las explica de forma sistemática y con un ejemplo continuo: los pedidos y la demanda semanal de NovaMarket que ya conoces. Veremos el ndarray, su forma y tipo, la indexación, el broadcasting y las máscaras booleanas; Series y DataFrame, lectura y escritura de CSV, selección con loc/iloc, valores ausentes, groupby, merge, fechas y remuestreo; y figuras con histogramas, barras, líneas y subgráficos, que describiremos en texto. Terminaremos con una tabla "operación → cómo se hace" que sirve de chuleta para el módulo 8. Es importante porque cualquier proyecto de IA dedica más tiempo a preparar y mirar datos con estas tres librerías que a llamar a fit. Lo que hacen scikit-learn y PyTorch con esos arrays es asunto de 07-03.
Contenido
- NumPy: el
ndarray, forma ydtype - Creación, indexación y slicing
- Operaciones vectorizadas y broadcasting
- Agregaciones por eje, máscaras booleanas y aleatoriedad
- Álgebra lineal básica y por qué NumPy es la base de todo
- pandas:
SeriesyDataFrame, lectura, escritura e inspección - Selección y filtrado:
loc,ilocy máscaras - Valores ausentes,
groupbyy agregaciones - Unir tablas con
merge - Fechas:
to_datetime,.dt,resampleyrolling applycon moderación- Matplotlib: figura, ejes y los cuatro gráficos básicos
- Tabla "operación → cómo se hace"
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- NumPy: el
ndarray, forma y dtype
ndarray, forma y dtypeUna lista de Python puede contener cualquier cosa ([1, "hola", 2.5]), y por eso cada elemento es un objeto separado con su tipo, su contador de referencias y su dirección de memoria: flexible y lento. El ndarray de NumPy es lo contrario: un bloque contiguo de memoria en el que todos los elementos tienen el mismo tipo (dtype) y una forma (shape) que dice cuántas dimensiones tiene y cuántos elementos por dimensión. Ese es el secreto del benchmark de 07-01: la CPU recorre el bloque sin preguntar nada.
import numpy as np
importes = np.array([129.90, 45.50, 899.00, 19.99, 249.00]) # 1 dimensión: un vector
print(importes, importes.dtype, importes.shape, importes.ndim)
# [129.9 45.5 899. 19.99 249. ] float64 (5,) 1
tabla = np.array([[129.90, 2, 3], # 2 dimensiones: una matriz de 4 filas x 3 columnas
[45.50, 1, 5], # (importe, num_articulos, dias_entrega)
[899.00, 1, 2],
[19.99, 3, 7]])
print(tabla.shape, tabla.dtype) # (4, 3) float64 <- todos float, aunque haya enteros
print(np.array([1, 2, 3]).dtype) # int64
print(np.array([1, 2.5]).dtype) # float64: NumPy promociona al tipo más generalshapees una tupla:(5,)es un vector de 5;(4, 3)una matriz de 4 × 3;(3000, 21)fue la matriz de características de 04-03 (3.000 pedidos, 21 columnas tras elColumnTransformer); una imagen 16 × 16 en 05-04 era(16, 16)y un lote de ellas(n, 1, 16, 16).dtypees el tipo común:float64(8 bytes, el habitual),float32(4 bytes, el que usa PyTorch por defecto),int64,bool. Un array de un millón defloat64ocupa 8 MB exactos; una lista Python equivalente, unas cuatro veces más.ndimes el número de dimensiones (ejes). En el vocabulario de la IA, un array de 1 dimensión es un vector, de 2 una matriz y de más un tensor.
- Creación, indexación y slicing
Formas de crear arrays que verás continuamente:
np.zeros(3) # [0. 0. 0.]
np.ones((2, 3)) # matriz 2x3 de unos
np.full(3, 7.5) # [7.5 7.5 7.5]
np.arange(0, 10, 2) # [0 2 4 6 8] como range, pero devuelve array
np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ] 5 puntos equiespaciados
np.eye(3) # matriz identidad 3x3Indexar y trocear funciona como en las listas, pero con una posición por eje separada por comas, y con la regla de que un trozo (slice) es una vista, no una copia:
tabla[0] # [129.9 2. 3. ] primera fila
tabla[0, 0] # 129.9 fila 0, columna 0
tabla[:, 0] # [129.9 45.5 899. 19.99] TODAS las filas, columna 0 (la de importes)
tabla[1:3] # filas 1 y 2 (la 3 no entra), todas las columnas
tabla[-1, -1] # 7.0 última fila, última columna
vista = tabla[:, 0] # vista de la columna de importes
vista[1] = 0.0 # ¡modifica tabla[1, 0] también!
copia = tabla[:, 0].copy() # si quieres independencia, copia explícitamenteQue un slice sea una vista es una decisión de diseño por eficiencia (no se copian megabytes al trocear), y una fuente clásica de sustos: si vas a modificar el trozo, usa .copy().
- Operaciones vectorizadas y broadcasting
Los operadores aritméticos actúan elemento a elemento sobre todo el array, sin bucle:
importes = np.array([129.90, 45.50, 899.00, 19.99, 249.00])
np.round(importes * 1.21, 2) # [ 157.18 55.06 1087.79 24.19 301.29] con IVA
importes - 5 # [124.9 40.5 894. 14.99 244. ] gastos de envío fuera
descuentos = np.array([0.10, 0.0, 0.15, 0.0, 0.05])
np.round(importes * (1 - descuentos), 2) # [116.91 45.5 764.15 19.99 236.55] array por array
np.log(importes).round(3) # [4.867 3.818 6.801 2.995 5.517] funciones "universales" (ufuncs)importes * 1.21 combina un array de forma (5,) con un escalar: NumPy "estira" el escalar hasta la forma del array. Esa regla generalizada se llama broadcasting: dos arrays pueden operar si, comparando sus formas de derecha a izquierda, cada par de dimensiones es igual o una de ellas vale 1 (o falta). Ejemplo numérico, la operación más común en ML, estandarizar columnas (restar la media de cada columna y dividir por su desviación; es lo que hace StandardScaler en 04-03):
tabla = np.array([[129.90, 2, 3], [45.50, 1, 5], [899.00, 1, 2], [19.99, 3, 7]]) # (4, 3)
media = tabla.mean(axis=0) # (3,) [273.5975 1.75 4.25 ] una media por columna
desv = tabla.std(axis=0) # (3,) [363.36 0.829 1.920]
z = (tabla - media) / desv # (4,3) - (3,) -> la fila (3,) se resta a CADA fila de tabla
print(z.round(3))
# [[-0.395 0.302 -0.651]
# [-0.628 -0.905 0.391]
# [ 1.721 -0.905 -1.172]
# [-0.698 1.508 1.432]]
col = np.array([[1.0], [2.0], [3.0], [4.0]]) # (4, 1)
fila = np.array([10.0, 20.0, 30.0]) # (3,)
print(col + fila) # (4,1) + (3,) -> (4,3): tabla de sumas
# [[11. 21. 31.]
# [12. 22. 32.]
# [13. 23. 33.]
# [14. 24. 34.]]
np.array([1, 2, 3]) + np.array([1, 2])
# ValueError: operands could not be broadcast together with shapes (3,) (2,)Comprobación de la regla: (4, 3) con (3,): comparando por la derecha, 3 = 3 y la otra dimensión falta, compatible. (4, 1) con (3,): 1 frente a 3 (vale, uno de ellos es 1), 4 frente a nada: resultado (4, 3). (3,) con (2,): 3 ≠ 2 y ninguno es 1: error. Cuando un cálculo de NumPy o PyTorch te dé un error de formas, este es el juego que hay que jugar.
- Agregaciones por eje, máscaras booleanas y aleatoriedad
Agregar es reducir un eje: sum, mean, std, min, max, argmax (posición del máximo). Sin axis reducen todo; con axis=0 recorren las filas y devuelven un valor por columna; con axis=1, un valor por fila:
tabla.sum() # 1118.39 todo
tabla.sum(axis=0) # [1094.39 7. 17. ] por columna: importe total, artículos, días
tabla.sum(axis=1) # [134.9 51.5 902. 29.99] por fila (sin sentido aquí, pero ilustra el eje)
tabla.max(axis=0) # [899. 3. 7.]
tabla.argmax(axis=0) # [2 3 3] en qué fila está el máximo de cada columnaRegla mnemotécnica: axis es el eje que desaparece. Con axis=0 desaparecen las filas y queda una cifra por columna.
Las máscaras booleanas son la manera vectorizada de filtrar y de contar, y las hemos usado sin nombrarlas desde el módulo 4 (rng.random(n) < 0.30 para decidir qué clientes son nuevos):
importe = np.array([129.90, 45.50, 899.00, 19.99, 249.00, 75.0])
devuelto = np.array([0, 0, 1, 0, 1, 0])
importe > 100 # [ True False True False True False] array de bool
importe[importe > 100] # [129.9 899. 249. ] filtra
(importe > 100).sum() # 3 True vale 1: cuenta cuántos cumplen
(importe > 100).mean() # 0.5 proporción que cumple
devuelto[importe > 100].mean() # 0.667 tasa de devolución de los pedidos caros
devuelto[importe <= 100].mean() # 0.0 y de los baratos
np.where(importe > 100, "alto", "bajo") # ['alto' 'bajo' 'alto' 'bajo' 'alto' 'bajo'] condicional vectorizado
importe[(importe > 40) & (devuelto == 0)] # [129.9 45.5 75. ] combinar con & (y), | (o), ~ (no)Ojo a los paréntesis: & tiene más prioridad que >, así que importe > 40 & devuelto == 0 sin paréntesis falla.
Aleatoriedad reproducible: desde NumPy 1.17 la forma recomendada es crear un generador con semilla, np.random.default_rng(semilla), y pedirle números. Es lo que hace generar_pedidos_ml y por eso los 3.000 pedidos son los mismos en todos los módulos:
rng = np.random.default_rng(42)
rng.random(3) # [0.774 0.439 0.859] uniformes en [0, 1)
rng.integers(1, 6, size=5) # [1 4 2 1 3] enteros de 1 a 5 (el 6 no entra)
rng.normal(0, 25, 3).round(1) # [ 3.2 -7.9 -0.4] normales de media 0 y desviación 25 (el ruido de la demanda)
rng.choice(["A", "B", "C"], size=6, p=[0.4, 0.35, 0.25]) # ['B' 'A' 'C' 'B' 'C' 'B'] zonas
np.random.default_rng(42).random(3) # [0.774 0.439 0.859] misma semilla, mismos números
- Álgebra lineal básica y por qué NumPy es la base de todo
El operador @ es el producto matricial, y con él se escribe en una línea lo que la regresión logística de 04-04 y cada capa nn.Linear de 05-02 hacen por dentro: z = X @ w + b:
X = np.array([[1.0, 129.90, 2], # 3 pedidos x 3 características (la primera es el sesgo)
[1.0, 45.50, 1],
[1.0, 899.00, 1]])
w = np.array([-3.4, 0.01, 0.35]) # pesos (inventados, del estilo de la "verdad oculta" de 04-01)
z = X @ w # (3,3) @ (3,) -> (3,) un logit por pedido
print(z, 1 / (1 + np.exp(-z)))
# [-1.401 -2.595 5.94 ] [0.198 0.069 0.997] sigmoide -> probabilidad de devolución
X.T # traspuesta, (3,3) aquí; de una (3000,21) sería (21,3000)
A = np.array([[2.0, 1.0], [1.0, 3.0]]); b = np.array([3.0, 5.0])
np.linalg.solve(A, b) # [0.8 1.4] resuelve A x = b (mejor que inv(A) @ b)np.linalg tiene además inv, det, eig (autovalores, base del PCA de 04-02), svd y norm. No hay que dominar el álgebra para seguir el curso; sí hay que saber que todo modelo es al final una secuencia de @ y funciones elemento a elemento sobre arrays.
Por eso NumPy es la base: scikit-learn recibe y devuelve arrays (o DataFrames que convierte en arrays), Matplotlib dibuja arrays, pandas guarda cada columna en un array, y los tensores de PyTorch de 05-03 son "arrays con gradiente": mismo concepto de forma, dtype y broadcasting, más requires_grad para que autograd apunte las operaciones, y torch.tensor(array) / tensor.numpy() para pasar de uno a otro sin copiar cuando el tipo lo permite. Quien entiende NumPy tiene el 80 % de PyTorch.
- pandas:
Series y DataFrame, lectura, escritura e inspección
Series y DataFrame, lectura, escritura e inspecciónNumPy no sabe que la columna 0 es "importe" ni entiende de fechas ni de valores ausentes en enteros. pandas pone encima de los arrays dos estructuras con etiquetas:
Series: un array unidimensional con un índice (etiquetas de fila) y un nombre.DataFrame: una tabla de columnas, cada una unaSeries(posiblemente de distintodtype), que comparten índice. Es lo que devuelvegenerar_pedidos_ml.
import pandas as pd
from novamarket_ml import generar_pedidos_ml, generar_demanda_semanal # generadores del módulo 4
s = pd.Series([129.90, 45.50, 899.00], index=["P1001", "P1002", "P1003"], name="importe")
print(s["P1002"], s.mean(), (s > 100).sum()) # 45.5 358.13 2 acceso por etiqueta; NumPy por debajo
pedidos = generar_pedidos_ml(3000, 42) # DataFrame de 3000 x 7
print(pedidos.shape) # (3000, 7)
print(pedidos.head()) # primeras 5 filas
pedidos.info() # columnas, no nulos, dtypes, memoria
print(pedidos.describe().round(2)) # estadísticos de las numéricas
print(pedidos["categoria"].value_counts()) # recuento por valorSalida (resumida) de info() y describe():
RangeIndex: 3000 entries, 0 to 2999
Data columns (total 7 columns):
# Column Non-Null Count Dtype
0 importe 3000 non-null float64
1 num_articulos 3000 non-null int64
2 dias_entrega 3000 non-null int64
3 cliente_nuevo 3000 non-null int64
4 categoria 3000 non-null str
5 codigo_postal_zona 3000 non-null str
6 devuelto 3000 non-null int64
memory usage: 164.2 KB
importe num_articulos dias_entrega cliente_nuevo devuelto
count 3000.00 3000.00 3000.00 3000.00 3000.00
mean 125.29 2.98 3.99 0.30 0.16
std 84.33 1.41 2.00 0.46 0.37
min 5.88 1.00 1.00 0.00 0.00
50% 108.12 3.00 4.00 0.00 0.00
max 632.61 5.00 7.00 1.00 1.00
categoria
electronica 1038
hogar 908
informatica 615
accesorios 439Ya reconoces las cifras: 30 % de clientes nuevos, tasa de devolución 0,16 (16,4 %), importe medio 125 € con mediana 108 (cola larga: la gamma del generador). Las columnas de texto aparecen como str en las versiones recientes de pandas (object en las anteriores). head(), info(), describe() y value_counts() son lo primero que hay que ejecutar sobre cualquier fichero nuevo, antes de pensar en modelos.
CSV de ida y vuelta, que es como circularán pedidos.csv y compañía por NovaMarket:
pedidos.to_csv("pedidos.csv", index=False) # index=False: no guardes el 0..2999 como columna
p2 = pd.read_csv("pedidos.csv") # infiere tipos; parse_dates=[...] para columnas de fecha
print(p2.shape) # (3000, 7)
# Primeras líneas del fichero:
# importe,num_articulos,dias_entrega,cliente_nuevo,categoria,codigo_postal_zona,devuelto
# 130.51,4,4,1,hogar,B,0read_csv acepta sep=";" (los CSV españoles de Excel), decimal=",", encoding="latin-1", usecols, nrows; y hay read_excel, read_sql (para la consulta de 07-01 directamente a un DataFrame), read_parquet (formato columnar comprimido, mucho más rápido que CSV para tablas grandes) y read_json.
- Selección y filtrado:
loc, iloc y máscaras
loc, iloc y máscarasTres formas de seleccionar, y conviene distinguirlas:
pedidos["importe"] # una columna -> Series
pedidos[["importe", "categoria"]] # varias columnas -> DataFrame (lista dentro de corchetes)
pedidos.loc[0] # fila con ETIQUETA 0 -> Series con una entrada por columna
pedidos.loc[0:2, ["importe", "devuelto"]] # etiquetas 0,1,2 (loc INCLUYE el final) y dos columnas
pedidos.iloc[0:2, 0:3] # POSICIONES: filas 0-1, columnas 0-2 (iloc excluye el final)
pedidos.iloc[-1, 0] # 94.06 último pedido, primera columna
caros = pedidos[pedidos["importe"] > 300] # máscara booleana, como en NumPy
print(len(caros), caros["devuelto"].mean().round(3)) # 134 0.612 <- el 61 % de los caros se devuelve
sel = pedidos[(pedidos["categoria"] == "electronica") & (pedidos["cliente_nuevo"] == 1)]
print(len(sel), sel["devuelto"].mean().round(3)) # 316 0.415 <- la fila del ejercicio 3 de 07-01
pedidos.query("importe > 300 and categoria == 'hogar'").shape # (37, 7) la misma idea, como texto
pedidos.sort_values("importe", ascending=False).head(3) # los tres pedidos más caros (632, 608, 578 €; los tres devueltos)locva por etiquetas (las del índice y los nombres de columna) e incluye el extremo;ilocva por posiciones enteras y lo excluye, como Python. Mientras el índice sea elRangeIndex0..n-1 coinciden, y por eso la confusión pasa desapercibida hasta que filtras o reordenas y las etiquetas dejan de ser posiciones.- Para asignar sobre una selección usa
df.loc[máscara, "columna"] = valor; asignar sobredf[máscara]["columna"]puede modificar una copia y no el original (el famosoSettingWithCopyWarning).
Crear columnas nuevas es asignar, y pd.cut discretiza en tramos (recuerda 04-03):
pedidos["importe_por_articulo"] = (pedidos["importe"] / pedidos["num_articulos"]).round(2)
pedidos["tramo"] = pd.cut(pedidos["importe"], bins=[0, 50, 150, 400, np.inf],
labels=["<50", "50-150", "150-400", ">400"])
print(pedidos.groupby("tramo", observed=True)["devuelto"].mean().round(3))
# <50 0.049 50-150 0.115 150-400 0.300 >400 0.743La tasa de devolución se multiplica por 15 entre el tramo más barato y el más caro: el +0.010 * (importe - 100) del generador visto desde los datos.
- Valores ausentes,
groupby y agregaciones
groupby y agregacionesEn 04-03 ensuciamos los pedidos con NaN y los imputamos dentro del pipeline. Las herramientas de pandas para verlos y tratarlos:
rng = np.random.default_rng(7)
sucio = pedidos.copy()
sucio.loc[rng.random(len(sucio)) < 0.05, "dias_entrega"] = np.nan # 5 % de entregas sin dato
sucio.loc[rng.random(len(sucio)) < 0.02, "importe"] = np.nan
print(sucio.isna().sum()) # importe 73, dias_entrega 142, resto 0
print(sucio["importe"].mean().round(2)) # 124.79 las agregaciones IGNORAN NaN por defecto
print(sucio.dropna().shape) # (2787, 7) eliminar filas con algún NaN: se pierden 213
relleno = sucio.fillna({"dias_entrega": sucio["dias_entrega"].median(),
"importe": sucio["importe"].median()}) # imputar por mediana
print(relleno.isna().sum().sum()) # 0
print(sucio["dias_entrega"].dtype) # float64: al meter NaN en una columna de enteros, pandas la pasa a floatisna()/notna(), dropna(), fillna(), y en modelos el SimpleImputer de 04-03 (que además recuerda la mediana de entrenamiento para aplicarla en producción, cosa que fillna a mano no hace).
groupby es la operación central del análisis: dividir en grupos, aplicar una agregación a cada uno y combinar. Es el GROUP BY de SQL de 07-01, y con él respondemos las preguntas de Diego:
pedidos.groupby("categoria")["devuelto"].mean().round(3)
# accesorios 0.130 electronica 0.207 hogar 0.135 informatica 0.159 (misma cifra que la consulta SQL)
tasa = pedidos.groupby("categoria")["devuelto"].agg(pedidos="count", devueltos="sum", tasa="mean").round(3)
print(tasa.sort_values("tasa", ascending=False))
# pedidos devueltos tasa
# electronica 1038 215 0.207
# informatica 615 98 0.159
# hogar 908 123 0.135
# accesorios 439 57 0.130
# Dos claves -> índice jerárquico; unstack() lleva la segunda clave a columnas
pedidos.groupby(["categoria", "codigo_postal_zona"])["devuelto"].mean().round(3).unstack()
# codigo_postal_zona A B C
# accesorios 0.128 0.123 0.140
# electronica 0.223 0.187 0.207
# hogar 0.121 0.162 0.117
# informatica 0.139 0.178 0.165
pedidos.groupby("cliente_nuevo").agg(importe_medio=("importe", "mean"),
tasa_dev=("devuelto", "mean"),
pedidos=("importe", "size")).round(3)
# importe_medio tasa_dev pedidos
# cliente_nuevo
# 0 124.813 0.091 2094
# 1 126.397 0.333 906Lecturas: por zona la tasa no varía de forma sistemática (0,12-0,22 sin patrón por columna): coherente con que codigo_postal_zona no influye en el generador, y con que en 04-04 el modelo le dio pesos casi nulos. Los clientes nuevos gastan casi lo mismo de media (126 € frente a 125 €) pero devuelven 3,7 veces más (33 % frente a 9 %). La sintaxis agg(nombre=("columna", "función")) (agregación con nombre) es la más legible; pd.crosstab(pedidos["categoria"], pedidos["devuelto"], normalize="index") da la misma tabla de tasas en formato de contingencia.
- Unir tablas con
merge
mergeLos datos de NovaMarket están repartidos: pedidos.csv no dice cuánto cuesta gestionar una devolución ni desde qué almacén sale. Eso está en productos.csv. Construimos una tabla pequeña por categoría (en la realidad sería por producto) y la unimos:
productos = pd.DataFrame({
"categoria": ["electronica", "hogar", "informatica", "accesorios"],
"producto_estrella": ["auriculares NovaSound", "robot aspirador NovaClean",
"portatil NovaBook", "funda NovaCase"],
"coste_devolucion": [12.0, 18.0, 25.0, 4.0], # euros por devolución gestionada
"almacen": ["Getafe", "Zaragoza", "Getafe", "Zaragoza"],
})
unido = pedidos.merge(productos, on="categoria", how="left") # clave: categoria; left: conserva todos los pedidos
print(unido.shape) # (3000, 10): 7 columnas + 3 nuevas
unido["coste_dev"] = unido["devuelto"] * unido["coste_devolucion"]
print(unido.groupby("almacen").agg(pedidos=("importe", "size"), devueltos=("devuelto", "sum"),
coste=("coste_dev", "sum")))
# pedidos devueltos coste
# Getafe 1653 313 5030.0
# Zaragoza 1347 180 2442.0ones la columna clave (si se llama distinto en cada tabla,left_on/right_on);howdecide qué pasa con las claves sin pareja:leftconserva todas las filas de la izquierda (pedidos) y poneNaNdonde no hay producto;innersolo las que casan en ambas;outertodas. Si quitas "accesorios" deproductos,innerdeja 2.561 pedidos yleftdeja 3.000 con 439almacenaNaN.joines lo mismo por índice;pd.concat([df1, df2])apila tablas (filas o columnas) sin clave.- Diego tiene su cifra: las devoluciones cuestan unos 7.500 € en estos 3.000 pedidos, dos tercios en Getafe (electrónica e informática). Es el tipo de dato que convierte "AUC 0,844" en "euros que se ahorran".
- Fechas:
to_datetime, .dt, resample y rolling
to_datetime, .dt, resample y rollingLa demanda semanal del NovaClean (generar_demanda_semanal, 04-02) trae una columna fecha de tipo datetime64. pandas convierte texto a fecha con pd.to_datetime y expone las partes con .dt:
demanda = generar_demanda_semanal(104, 42) # 104 semanas: 2024 y 2025, lunes
print(demanda.dtypes) # semana int64, fecha datetime64, unidades int64
pd.to_datetime("24/11/2025", dayfirst=True) # Timestamp('2025-11-24') cuidado con el formato español
demanda["fecha"].dt.year.value_counts().sort_index() # 2024: 53 semanas, 2025: 51
demanda["mes"] = demanda["fecha"].dt.month # también .dt.day, .dt.dayofweek, .dt.day_name(), .dt.quarter
demanda[demanda["fecha"] >= "2025-11-01"].head(4) # comparar con texto funciona
# semana fecha unidades
# 96 97 2025-11-03 573
# 97 98 2025-11-10 578
# 98 99 2025-11-17 610
# 99 100 2025-11-24 825 <- Black Friday
# Remuestrear de semanal a mensual: el índice debe ser la fecha
mensual = demanda.set_index("fecha")["unidades"].resample("MS").sum() # MS = inicio de mes
print(mensual.head(4)); print(len(mensual), mensual.idxmax(), mensual.max())
# 2024-01-01 1714 / 2024-02-01 1459 / 2024-03-01 1656 / 2024-04-01 2261 ... 24 meses; máximo junio 2025: 3268
# Media móvil: suaviza el ruido semanal
demanda["media_movil_4"] = demanda["unidades"].rolling(4).mean() # NaN en las 3 primerasresample acepta "W", "MS", "QS" (trimestre), "YS", y cualquier agregación (sum, mean, max); rolling(k) calcula ventanas deslizantes (center=True para centrarlas). Cuidado con la trampa clásica de las mensualidades: un mes con cinco lunes suma cinco semanas y otro cuatro; para comparar meses en serio hay que normalizar por semanas o trabajar con días.
apply con moderación
apply con moderaciónapply ejecuta una función Python fila a fila o valor a valor. Es cómodo y es un bucle Python disfrazado (07-01): úsalo cuando no haya alternativa vectorizada, y prefiere map para diccionarios, .str para texto y pd.cut/np.where para tramos y condicionales:
def tramo(imp):
return "bajo" if imp < 50 else ("medio" if imp < 150 else "alto")
a = pedidos["importe"].apply(tramo) # funciona, pero es un bucle Python
b = pd.cut(pedidos["importe"], [0, 50, 150, np.inf], labels=["bajo", "medio", "alto"], right=False) # vectorizado
# ambos: {'medio': 1596, 'alto': 898, 'bajo': 506}; con 3.000 filas no hay diferencia; con 3 millones sí
pedidos["categoria"].map({"electronica": "ELEC", "hogar": "HOG", "informatica": "INFO", "accesorios": "ACC"})
pedidos["categoria"].str.upper(); pedidos["categoria"].str.len(); pedidos["categoria"].str.contains("elec")
- Matplotlib: figura, ejes y los cuatro gráficos básicos
Matplotlib tiene dos capas: la rápida (plt.plot(...)) y la orientada a objetos, que es la que conviene aprender: una figura (fig, el lienzo) contiene uno o más ejes (ax, cada gráfico), y sobre los ejes se dibuja y se etiqueta. Como el entorno del curso no muestra imágenes, guardamos a fichero y describimos lo que se ve.
import matplotlib.pyplot as plt
# 1) Histograma de importes
fig, ax = plt.subplots(figsize=(7, 4)) # una figura con un eje
ax.hist(pedidos["importe"], bins=40, color="steelblue", edgecolor="white")
ax.axvline(pedidos["importe"].median(), color="darkred", linestyle="--",
label=f"mediana {pedidos['importe'].median():.0f} EUR") # línea vertical
ax.set_xlabel("Importe del pedido (EUR)"); ax.set_ylabel("Número de pedidos")
ax.set_title("NovaMarket: distribución de importes (3.000 pedidos)")
ax.legend()
fig.tight_layout(); fig.savefig("hist_importes.png", dpi=120) # también .pdf, .svg
# 2) Barras: tasa de devolución por categoría
tasa = pedidos.groupby("categoria")["devuelto"].mean().sort_values(ascending=False)
fig, ax = plt.subplots(figsize=(6, 4))
barras = ax.bar(tasa.index, tasa.values * 100, color=["firebrick", "darkorange", "goldenrod", "seagreen"])
ax.bar_label(barras, fmt="%.1f %%") # el valor encima de cada barra
ax.axhline(pedidos["devuelto"].mean() * 100, color="gray", linestyle=":", label="media global")
ax.set_ylabel("Tasa de devolución (%)"); ax.set_title("Tasa de devolución por categoría"); ax.legend()
fig.tight_layout(); fig.savefig("barras_tasa.png", dpi=120)
# 3) Línea: demanda semanal y media móvil
demanda["media_movil_8"] = demanda["unidades"].rolling(8, center=True).mean()
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(demanda["fecha"], demanda["unidades"], color="lightgray", marker=".", linewidth=1, label="unidades semanales")
ax.plot(demanda["fecha"], demanda["media_movil_8"], color="navy", linewidth=2, label="media móvil (8 semanas)")
pico = demanda.loc[demanda["unidades"].idxmax()]
ax.annotate("Black Friday", xy=(pico["fecha"], pico["unidades"]),
xytext=(pico["fecha"], pico["unidades"] + 60), arrowprops=dict(arrowstyle="->"), ha="center")
ax.set_xlabel("Semana"); ax.set_ylabel("Unidades vendidas")
ax.set_title("Demanda semanal del robot aspirador NovaClean"); ax.legend(loc="upper left"); ax.grid(alpha=0.3)
fig.tight_layout(); fig.savefig("demanda_media_movil.png", dpi=120)
# 4) Subgráficos: dispersión y línea, lado a lado
fig, ejes = plt.subplots(1, 2, figsize=(10, 4)) # 1 fila x 2 columnas -> array de ejes
ejes[0].scatter(pedidos["importe"], pedidos["dias_entrega"], c=pedidos["devuelto"], cmap="coolwarm", s=8, alpha=0.6)
ejes[0].set_xlabel("Importe (EUR)"); ejes[0].set_ylabel("Días de entrega"); ejes[0].set_title("Pedidos (rojo = devuelto)")
por_dia = pedidos.groupby("dias_entrega")["devuelto"].mean() * 100
ejes[1].plot(por_dia.index, por_dia.values, marker="o", color="darkred")
ejes[1].set_xlabel("Días de entrega"); ejes[1].set_ylabel("Tasa de devolución (%)"); ejes[1].set_title("Tasa según días de entrega")
fig.suptitle("Importe, entrega y devoluciones"); fig.tight_layout(); fig.savefig("subplots.png", dpi=120)Qué se ve en cada fichero:
hist_importes.png: una campana asimétrica con el pico en la barra de 69-84 € (277 pedidos), la línea discontinua de la mediana en 108 € a la derecha del pico y una cola que se alarga hasta 630 € con pocas decenas de pedidos por encima de 400. Es la distribución gamma del generador y la razón por la que en 04-03 hablamos de escalar y de atípicos.barras_tasa.png: cuatro barras descendentes, electrónica 20,7 %, informática 15,9 %, hogar 13,5 %, accesorios 13,0 %, con la línea punteada de la media global (16,4 %) cruzando entre las dos primeras y las dos últimas.demanda_media_movil.png: puntos grises que oscilan cada semana entre 308 y 825 unidades, y sobre ellos una línea azul suave que sube de unas 350 unidades a principios de 2024 hasta unas 650 a mediados de 2025 con dos "jorobas" anuales (máximo hacia julio, mínimo hacia enero) y dos picos aislados en gris que la media móvil casi ignora: la semana del Black Friday de 2024 (724 unidades) y la de 2025 (825), esta última con la flecha "Black Friday". Tendencia + estacionalidad + evento, exactamente lo que dijimos que había en 04-02.subplots.png: a la izquierda, una nube de puntos con siete franjas horizontales (días de entrega 1-7), casi toda azul (no devuelto) y con puntos rojos concentrados hacia la derecha (importes altos) y hacia arriba (entregas de 5-7 días); a la derecha, una línea que sube casi en recta de 8,6 % de devoluciones con entrega en 1 día a 26 % con 7 días. Es la relación que el+0.35 * (dias_entrega - 4)del generador esconde.
Guarda las figuras con fig.savefig; en un notebook (07-04) se muestran solas. Cuando el gráfico sea estadístico y quieras que salga bien a la primera, seaborn (encima de Matplotlib: sns.histplot, sns.barplot, sns.heatmap para matrices de correlación y de confusión) ahorra código; para gráficos interactivos en web, plotly. Las dos usan DataFrames directamente. El propio pandas tiene df.plot() como atajo sobre Matplotlib.
- Tabla "operación → cómo se hace"
| Operación (lo que hicimos en el módulo 4) | NumPy | pandas |
|---|---|---|
| Crear datos reproducibles | rng = np.random.default_rng(42); rng.normal, rng.integers, rng.choice |
pd.DataFrame({...}) con los arrays |
| Filtrar filas por condición | a[a > 100], a[(c1) & (c2)] |
df[df["importe"] > 100], df.query("...") |
| Condicional vectorizado | np.where(cond, x, y) |
np.where sobre columnas, pd.cut para tramos |
| Contar / proporción que cumple | (a > 100).sum() / .mean() |
(df["x"] > 100).sum(), df["devuelto"].mean() |
| Media/desv por columna | a.mean(axis=0), a.std(axis=0) |
df.mean(numeric_only=True), df.describe() |
| Estandarizar | (a - a.mean(0)) / a.std(0) (broadcasting) |
igual, o StandardScaler (07-03) |
| Tasa por grupo | (a mano con máscaras por valor) | df.groupby("cat")["devuelto"].mean() |
| Varias agregaciones con nombre | — | df.groupby("k").agg(n=("x","size"), m=("y","mean")) |
| Unir dos tablas por clave | — | df1.merge(df2, on="clave", how="left") |
| Nuevas columnas derivadas | a[:,0] / a[:,1] |
df["c"] = df["a"] / df["b"] |
| Valores ausentes | np.isnan(a), np.nanmean(a) |
df.isna().sum(), df.dropna(), df.fillna({...}) |
| Fechas | np.datetime64 (básico) |
pd.to_datetime, .dt.month, resample("MS").sum(), rolling(4).mean() |
| Producto matricial / capa lineal | X @ w + b |
df.values @ w |
| Leer/escribir | np.loadtxt, np.save |
pd.read_csv, df.to_csv(index=False), read_sql, read_parquet |
| Convertir a scikit-learn / PyTorch | ya es un array | df.values / df.to_numpy(); torch.tensor(df.values, dtype=torch.float32) |
Errores Comunes y Consejos
- Bucles
forsobre filas de un DataFrame (for i in range(len(df)),iterrows). Es el error número uno de quien llega de otros lenguajes; casi siempre existe una operación vectorizada, ungroupbyo unmergeque lo hace en una línea y cien veces más rápido. - Modificar una vista creyendo que es una copia (NumPy) o una copia creyendo que es la vista (pandas con
df[mask]["col"] = ...). Reglas:.copy()cuando quieras independencia;df.loc[mask, "col"] = ...para asignar. - Confundir
loceiloctras filtrar u ordenar:df.loc[0]es la fila con etiqueta 0, que puede no existir;df.iloc[0]es la primera fila.reset_index(drop=True)tras un filtro te devuelve etiquetas 0..n-1 si las necesitas. - Formas incompatibles: lee el mensaje
could not be broadcast together with shapes (a,) (b,)y aplica la regla de derecha a izquierda;reshape(-1, 1)convierte un vector(n,)en columna(n, 1), lo que scikit-learn exige cuando hay una sola característica. - Fechas leídas como texto: tras
read_csv,df.dtypesdebe decirdatetime64; si diceobject/str, aplicapd.to_datetime(..., dayfirst=True)oparse_datesen la lectura. Y en un CSV español revisasep=";"ydecimal=",". NaNsilenciosos:mean()los ignora y puedes no enterarte de que faltan datos.info()eisna().sum()siempre al principio.- Gráficos sin etiquetas ni unidades: un
ax.set_xlabelcuesta un segundo y evita que Diego pregunte "¿esto son euros o unidades?". Y guarda siempre contight_layout()para que no se corten los textos.
Ejercicios
Ejercicio 1. Con NumPy puro (sin pandas), a partir de pedidos["importe"].to_numpy() y pedidos["devuelto"].to_numpy(): (a) calcula la tasa de devolución de los pedidos por encima y por debajo de la mediana de importe usando máscaras; (b) estandariza el importe con broadcasting y comprueba que la media resultante es ≈ 0 y la desviación ≈ 1; (c) construye con np.where una etiqueta "caro"/"barato" y cuenta cuántos hay de cada una con una máscara.
Ejercicio 2. Con pandas: calcula el importe medio y la tasa de devolución por categoría y por cliente nuevo/recurrente en una sola tabla con groupby y agregación con nombre, y llévala a formato ancho con unstack para que las columnas sean nuevo/recurrente. ¿En qué categoría es mayor la diferencia de tasa entre nuevos y recurrentes? Después, une con la tabla productos de la sección 9 y calcula el coste total de devoluciones por almacén y tipo de cliente.
Ejercicio 3. Con la demanda semanal: (a) remuestrea a trimestres ("QS") sumando unidades e identifica el trimestre con más ventas; (b) calcula la media móvil de 12 semanas y la variación porcentual semana a semana de la serie suavizada (pct_change), y localiza la semana con mayor subida relativa; (c) dibuja (guardando a fichero) las unidades por año como dos líneas superpuestas (día del año en el eje x, una línea por año; pista: .dt.year y .dt.dayofyear) y describe qué se ve.
Soluciones
Solución 1.
importe = pedidos["importe"].to_numpy(); devuelto = pedidos["devuelto"].to_numpy()
mediana = np.median(importe)
print(devuelto[importe > mediana].mean().round(3), devuelto[importe <= mediana].mean().round(3))
# 0.254 0.075 -> los pedidos caros se devuelven más de 3 veces más
z = (importe - importe.mean()) / importe.std()
print(z.mean().round(10), z.std().round(6)) # 0.0 1.0 (redondeo: la media es del orden de 1e-16)
etiqueta = np.where(importe > mediana, "caro", "barato")
print((etiqueta == "caro").sum(), (etiqueta == "barato").sum()) # 1500 1500Con 3.000 valores y mediana en la posición central, la mitad exacta queda a cada lado. Fíjate en que ni siquiera hemos necesitado pandas: es lo que scikit-learn hace por debajo con las columnas numéricas.
Solución 2.
t = pedidos.groupby(["categoria", "cliente_nuevo"]).agg(importe_medio=("importe", "mean"),
tasa=("devuelto", "mean")).round(3)
ancho = t["tasa"].unstack().rename(columns={0: "recurrente", 1: "nuevo"})
ancho["diferencia"] = ancho["nuevo"] - ancho["recurrente"]
print(ancho.sort_values("diferencia", ascending=False))
# recurrente nuevo diferencia
# electronica 0.116 0.415 0.299
# hogar 0.069 0.297 0.228
# informatica 0.099 0.304 0.205
# accesorios 0.068 0.259 0.191
unido = pedidos.merge(productos, on="categoria", how="left")
unido["coste_dev"] = unido["devuelto"] * unido["coste_devolucion"]
print(unido.groupby(["almacen", "cliente_nuevo"])["coste_dev"].sum().unstack())
# cliente_nuevo 0 1
# almacen
# Getafe 2083.0 2947.0
# Zaragoza 872.0 1570.0La mayor diferencia está en electrónica (30 puntos): el generador combina el efecto de categoría con el de cliente nuevo y el término cruzado nuevo × importe, y la electrónica tiene importes altos. En coste, los clientes nuevos (el 30 % de los pedidos) generan el 60 % del coste de devoluciones en los dos almacenes (2.947 € de 5.030 en Getafe; 1.570 € de 2.442 en Zaragoza): un argumento cuantificado para que Diego priorice el caso de uso 3 en clientes nuevos.
Solución 3.
serie = demanda.set_index("fecha")["unidades"]
trim = serie.resample("QS").sum()
print(trim.idxmax().date(), trim.max()) # 2025-07-01 8404 (tercer trimestre de 2025)
suave = serie.rolling(12).mean()
var = suave.pct_change() * 100
print(var.idxmax().date(), var.max().round(2)) # 2024-04-22 3.4 % (semana en que entra la subida de primavera)
demanda["anio"] = demanda["fecha"].dt.year; demanda["dia_anio"] = demanda["fecha"].dt.dayofyear
fig, ax = plt.subplots(figsize=(9, 4))
for anio, g in demanda.groupby("anio"): # un grupo (y una línea) por año
ax.plot(g["dia_anio"], g["unidades"], marker=".", label=str(anio))
ax.set_xlabel("Día del año"); ax.set_ylabel("Unidades"); ax.set_title("NovaClean: demanda semanal, un año por línea")
ax.legend(); fig.tight_layout(); fig.savefig("demanda_por_anio.png", dpi=120)Se ven dos líneas con la misma forma (suben hasta el verano, bajan hasta el invierno, con un pico aislado a finales de noviembre, el Black Friday), la de 2025 desplazada unas 130 unidades por encima de la de 2024 en toda su longitud (medias anuales: 471 y 597 unidades): la tendencia (+2,5 unidades por semana × 52 semanas) y la estacionalidad separadas a simple vista. Ese es el gráfico que Marta enseñaría a Diego antes de hablar de modelos de previsión (caso de uso 2).
Conclusión
Hemos abierto la caja de herramientas básica del Python científico con los datos de NovaMarket. NumPy aporta el ndarray (bloque contiguo, shape y dtype), la creación e indexación con vistas, las operaciones vectorizadas y el broadcasting (con el que estandarizamos columnas en una línea), las agregaciones por eje, las máscaras booleanas para filtrar y contar, el generador default_rng que hace reproducibles nuestros 3.000 pedidos y el producto @ con el que se escribe cualquier capa lineal; y es la base de todo lo demás, incluidos los tensores de PyTorch, que son arrays con gradiente. pandas añade etiquetas: Series y DataFrame, read_csv/to_csv, la inspección obligatoria con head/info/describe, la selección con loc/iloc y máscaras, el tratamiento de NaN, el groupby que responde a las preguntas de Diego (electrónica 20,7 % de devoluciones; clientes nuevos 3,7 veces más), el merge con la tabla de productos que convierte devoluciones en euros por almacén, y las fechas con .dt, resample (semanal a mensual) y rolling. Matplotlib dibuja con figura y ejes: histograma de importes, barras de tasa, la línea de la demanda con su media móvil y el Black Friday señalado, y subgráficos. La tabla de la sección 13 resume cómo se hace cada operación que en el módulo 4 dimos por sabida.
Con arrays y tablas dominados, el siguiente paso del taller de Marta es el mapa de las librerías que se construyen encima: scikit-learn y su API fit/predict, PyTorch frente a TensorFlow/Keras, Hugging Face para el lenguaje, OpenCV para la visión, los SDK de LLM, experta y pgmpy para las reglas y la probabilidad, OR-Tools para la optimización, y las herramientas para guardar, servir y seguir modelos. Es 07-03, Herramientas y Librerías Populares, donde además guardaremos y recargaremos el pipeline de 04-03 y el MLP de 05-02 y comprobaremos que siguen prediciendo lo mismo.
Fundamentos de Inteligencia Artificial (IA)
Módulo 1: Introducción a la Inteligencia Artificial
Módulo 2: Principios Básicos de la IA
- Conceptos Fundamentales: Agentes, Entornos y Racionalidad
- Tipos de Inteligencia Artificial
- Los Datos como Materia Prima de la IA
- Ética y Consideraciones en IA
Módulo 3: Algoritmos en IA
- Introducción a los Algoritmos
- Algoritmos de Búsqueda
- Búsqueda con Adversario: Juegos y Minimax
- Algoritmos de Optimización
Módulo 4: Aprendizaje Automático (Machine Learning)
- Conceptos Básicos de Machine Learning
- Tipos de Aprendizaje Automático
- Preparación de Datos y Características
- Algoritmos de Machine Learning
- Evaluación y Validación de Modelos
- Sobreajuste, Regularización y Ajuste de Hiperparámetros
Módulo 5: Redes Neuronales y Deep Learning
- Introducción a las Redes Neuronales
- Arquitectura de Redes Neuronales
- Cómo Aprende una Red: Descenso del Gradiente y Retropropagación
- Deep Learning y sus Aplicaciones
- Transformers, Grandes Modelos de Lenguaje e IA Generativa
Módulo 6: Lógica y Sistemas Expertos
- Lógica en IA
- Sistemas Expertos
- Razonamiento con Incertidumbre: Probabilidad y Redes Bayesianas
- Aplicaciones de Sistemas Expertos
Módulo 7: Herramientas y Lenguajes de Programación en IA
- Lenguajes de Programación para IA
- Python Científico: NumPy, pandas y Matplotlib
- Herramientas y Librerías Populares
- Entornos de Desarrollo
Módulo 8: Proyectos y Casos de Estudio
Módulo 9: Ejercicios y Prácticas
- Ejercicios de Algoritmos
- Prácticas de Machine Learning
- Proyectos de Redes Neuronales
- Proyecto Integrador: de la Idea al Prototipo
