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

  1. NumPy: el ndarray, forma y dtype
  2. Creación, indexación y slicing
  3. Operaciones vectorizadas y broadcasting
  4. Agregaciones por eje, máscaras booleanas y aleatoriedad
  5. Álgebra lineal básica y por qué NumPy es la base de todo
  6. pandas: Series y DataFrame, lectura, escritura e inspección
  7. Selección y filtrado: loc, iloc y máscaras
  8. Valores ausentes, groupby y agregaciones
  9. Unir tablas con merge
  10. Fechas: to_datetime, .dt, resample y rolling
  11. apply con moderación
  12. Matplotlib: figura, ejes y los cuatro gráficos básicos
  13. Tabla "operación → cómo se hace"
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. NumPy: el ndarray, forma y dtype

Una 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 general
  • shape es 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 el ColumnTransformer); una imagen 16 × 16 en 05-04 era (16, 16) y un lote de ellas (n, 1, 16, 16).
  • dtype es 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 de float64 ocupa 8 MB exactos; una lista Python equivalente, unas cuatro veces más.
  • ndim es 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.

  1. 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 3x3

Indexar 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ícitamente

Que 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().

  1. 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.

  1. 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 columna

Regla 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

  1. Á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.

  1. pandas: Series y DataFrame, lectura, escritura e inspección

NumPy 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 una Series (posiblemente de distinto dtype), que comparten índice. Es lo que devuelve generar_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 valor

Salida (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      439

Ya 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,0

read_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.

  1. Selección y filtrado: loc, iloc y máscaras

Tres 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)
  • loc va por etiquetas (las del índice y los nombres de columna) e incluye el extremo; iloc va por posiciones enteras y lo excluye, como Python. Mientras el índice sea el RangeIndex 0..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 sobre df[máscara]["columna"] puede modificar una copia y no el original (el famoso SettingWithCopyWarning).

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.743

La 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.

  1. Valores ausentes, groupby y agregaciones

En 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 float

isna()/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      906

Lecturas: 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.

  1. Unir tablas con merge

Los 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.0
  • on es la columna clave (si se llama distinto en cada tabla, left_on/right_on); how decide qué pasa con las claves sin pareja: left conserva todas las filas de la izquierda (pedidos) y pone NaN donde no hay producto; inner solo las que casan en ambas; outer todas. Si quitas "accesorios" de productos, inner deja 2.561 pedidos y left deja 3.000 con 439 almacen a NaN.
  • join es 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".

  1. Fechas: to_datetime, .dt, resample y rolling

La 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 primeras

resample 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.

  1. apply con moderación

apply 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")

  1. 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.

  1. 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 for sobre 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, un groupby o un merge que 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 loc e iloc tras 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.dtypes debe decir datetime64; si dice object/str, aplica pd.to_datetime(..., dayfirst=True) o parse_dates en la lectura. Y en un CSV español revisa sep=";" y decimal=",".
  • NaN silenciosos: mean() los ignora y puedes no enterarte de que faltan datos. info() e isna().sum() siempre al principio.
  • Gráficos sin etiquetas ni unidades: un ax.set_xlabel cuesta un segundo y evita que Diego pregunte "¿esto son euros o unidades?". Y guarda siempre con tight_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 1500

Con 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.0

La 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

Módulo 3: Algoritmos en IA

Módulo 4: Aprendizaje Automático (Machine Learning)

Módulo 5: Redes Neuronales y Deep Learning

Módulo 6: Lógica y Sistemas Expertos

Módulo 7: Herramientas y Lenguajes de Programación en IA

Módulo 8: Proyectos y Casos de Estudio

Módulo 9: Ejercicios y Prácticas

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados