NumPy te dio velocidad, pero a cambio de números anónimos: la fila 2 "era" El Quijote solo porque tú lo recordabas. pandas envuelve esos arrays en dos estructuras con nombre — la Series y el DataFrame — y añade todo lo que un análisis real necesita: leer CSV en una línea, fechas de verdad, limpieza de datos sucios y, sobre todo, groupby. Esta es la lección donde por fin respondemos con datos las preguntas que Ana arrastra desde el cierre de M10: qué día de la semana vende más, qué título triunfa en la web y cuál se consulta mucho pero se compra poco.
Contenido
- Series y DataFrame: anatomía
read_csv: elDictReaderde M6 con esteroides- Inspección:
head,info,describe,shape - Selección: columnas,
loc/ilocy máscaras booleanas - Columnas nuevas y fechas con
.dt - Limpieza:
isna,fillna,dropna,duplicated groupby: responder las preguntas de Anasort_valuesyvalue_countsmerge: unir con el catálogoto_csv: guardar resultados
Series y DataFrame: anatomía
Una Series es un array de NumPy con etiquetas (un índice); un DataFrame es una tabla: varias Series que comparten índice, una por columna.
flowchart LR
subgraph DF["DataFrame df"]
direction LR
I["índice<br>0<br>1<br>2"] --- C1["fecha<br>2026-01-02<br>2026-01-02<br>2026-01-03"] --- C2["titulo<br>La Odisea<br>Hamlet<br>El Quijote"] --- C3["importe<br>12.50<br>19.90<br>15.71"]
end
C3 -.->|"cada columna es<br>una Series<br>(un ndarray + índice)"| S["df['importe']"]
import pandas as pd # alias universal, como np
s = pd.Series([12.50, 9.95, 15.90, 21.00],
index=["La Odisea", "Hamlet", "El Quijote", "Fausto"])
print(s["Fausto"]) # 21.0 -> acceso por etiqueta, como un dict (M4)
print(s.mean()) # 14.8375 -> matemáticas vectorizadas, como NumPy (11-02)La Series es el híbrido perfecto entre el dict de M4 (acceso por clave) y el ndarray de 11-02 (vectorización). El DataFrame, entre una lista de dicts (las filas de DictReader) y una matriz 2D.
read_csv: el DictReader de M6 con esteroides
En M6 leías ventas.csv con csv.DictReader, convirtiendo tipos a mano fila a fila. La promesa era que pandas haría eso "con esteroides". Aquí está el canje:
Una línea y ya está todo: el archivo abierto y cerrado (el with de M8 va incluido), la cabecera detectada, unidades como entero, importe como float y — gracias a parse_dates — fecha como fecha de verdad, no como texto. Lo que en M6 eran quince líneas de bucle y conversiones.
csv.DictReader (M6) |
pd.read_csv |
|---|---|
| Itera fila a fila (lazy, memoria mínima) | Carga todo en memoria de golpe |
Todo es str: conviertes tú |
Infiere tipos; parse_dates para fechas |
| Una pregunta = un bucle | Una pregunta = una expresión |
La honestidad de M8 sigue vigente: si el archivo no cabe en memoria, el pipeline de generadores gana. Para las 487 filas de Papyrus (y para casi todo lo que quepa en RAM, que hoy es muchísimo), pandas es la herramienta.
Inspección: lo primero al cargar cualquier dato
fecha titulo unidades importe canal
0 2026-01-02 La Odisea 1 12.50 tienda
1 2026-01-02 Hamlet 2 19.90 web
2 2026-01-03 El Quijote 1 15.71 tiendadf.info() # tipos y nulos por columna: fecha datetime64[ns], unidades int64...
print(df["importe"].describe())count 487.000000
mean 15.030000
std 8.420000
min 9.830000
25% 9.950000
50% 12.500000
75% 15.900000
max 63.600000describe es la estadística de 11-01 en una llamada — y confirma lo que vimos allí: media (15.03) por encima de la mediana (12.50), con un máximo de 63.60 € (un pack de Sant Jordi) tirando hacia arriba.
Selección: columnas, loc/iloc y máscaras
df["titulo"] # una columna -> Series
df[["titulo", "importe"]] # varias columnas -> DataFrame (ojo: lista dentro de corchetes)Para filas hay dos accesores, y confundirlos es el tropiezo clásico:
loc |
iloc |
|
|---|---|---|
| Selecciona por | etiqueta del índice | posición entera |
df.loc[3] / df.iloc[3] |
La fila cuya etiqueta es 3 |
La cuarta fila, esté donde esté |
Rango [0:3] |
Incluye el extremo final | Excluye el final (como las listas) |
| Uso típico | df.loc[mascara, "importe"] |
"dame las 10 primeras filas" |
Con el índice por defecto (0, 1, 2...) ambos parecen iguales; en cuanto filtras o reordenas, dejan de serlo — el índice conserva las etiquetas originales.
Y la herencia directa de 11-02: las máscaras booleanas funcionan idénticas, con &, |, ~ y sus paréntesis:
ventas_web = df[df["canal"] == "web"]
grandes_abril = df[(df["importe"] > 30) & (df["fecha"].dt.month == 4)]
print(len(ventas_web), len(grandes_abril)) # 195 22Columnas nuevas y fechas con .dt
Crear una columna es asignar a un nombre nuevo; la expresión se vectoriza sola:
df["importe_unitario"] = (df["importe"] / df["unidades"]).round(2)
# ¿Se cobró a tarifa de socio? (los cuatro valores canónicos de M3)
df["es_socio"] = df["importe_unitario"].isin([12.35, 9.83, 15.71, 20.75])
print(df["es_socio"].sum()) # 141 ventas a sociosLas columnas datetime64 tienen el accesor .dt con todo lo que en 11-01 hacíamos con date.fromisoformat a mano:
df["mes"] = df["fecha"].dt.month
df["dia_semana"] = df["fecha"].dt.dayofweek # 0=lunes ... 6=domingo
dias = ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"]
df["dia_nombre"] = df["dia_semana"].map(dict(enumerate(dias)))Limpieza: el mundo real llega sucio
El fichero del módulo está curado, pero el volcado bruto que Ana consolida cada noche — ventas_2026_raw.csv, 495 filas — trae los sospechosos habituales de M7: filas duplicadas por un doble clic en la web y ventas con importe vacío por un fallo al guardar.
raw = pd.read_csv("datos/ventas_2026_raw.csv", parse_dates=["fecha"])
print(raw.shape) # (495, 5)
print(raw.duplicated().sum()) # 3 -> filas EXACTAMENTE repetidas
print(raw["importe"].isna().sum()) # 5 -> huecos (NaN) en importeNaN (Not a Number) es el marcador de "dato ausente". Las opciones, con criterio:
limpio = raw.drop_duplicates() # 495 -> 492 filas
limpio = limpio.dropna(subset=["importe"]) # 492 -> 487 filas
print(limpio.shape) # (487, 5) -> nuestro ventas_2026.csv¿Y fillna? Rellenar tiene sentido cuando conoces el valor correcto (raw["canal"].fillna("tienda") si sabes que el fallo solo ocurre en caja) o un neutro defendible. Inventarse un importe medio para una venta concreta, en cambio, contamina los totales: aquí eliminar es más honesto que imputar. En M7 estas filas corruptas lanzaban excepciones; ahora tienen tratamiento estadístico. Documenta siempre cuántas filas quitas y por qué.
groupby: responder las preguntas de Ana
groupby parte el DataFrame en grupos, aplica una agregación a cada uno y recompone el resultado. Es el patrón "dict acumulador" de M4 y el Counter en una sola llamada. Turno de las preguntas del cierre de M10.
¿Qué se vende más?
¿Qué día de la semana vende más? — pregunta 2 del gancho, respondida:
Sábado, con 130 unidades — casi el triple que el lunes. El fin de semana (sáb+dom) concentra el 44 % de las unidades: Marta ya sabe qué días no puede faltar nadie en la tienda. (El reindex(dias) fuerza el orden cronológico; sin él, pandas ordena alfabéticamente.)
¿Qué título triunfa en cada canal? — agrupar por dos columnas y pivotar para leerlo cómodo:
En tienda manda El Quijote (121); en la web, sorpresa: La Odisea (75) supera al Quijote (61). Hipótesis de Ana: en la web pesa el público del club de lectura de Júlia. Sea cual sea la causa, el escaparate de la home de Flask ya sabe qué portada poner.
¿Qué se consulta y no se compra? — pregunta 1 del gancho. Las ventas solas no bastan (lo previmos en 11-01): hacen falta las visitas a /libro/<titulo> de los logs de M10:
visitas = pd.Series({"El Quijote": 260, "La Odisea": 220,
"Fausto": 210, "Hamlet": 150}, name="visitas_web")
compras_web = df[df["canal"] == "web"].groupby("titulo")["unidades"].sum()
print((compras_web / visitas * 100).round(1).sort_values())Ahí está: Fausto se mira mucho (210 visitas, tercero del catálogo) pero solo el 11.9 % de las visitas acaban en compra — un tercio de la conversión de los demás. Con su precio de 21.00 €, la hipótesis del precio se impone sola. Dato, no opinión: exactamente lo que Ana pedía.
sort_values y value_counts
df.sort_values("importe", ascending=False).head(3) # las 3 ventas más grandes
df.sort_values(["fecha", "importe"]) # multiclave, como sorted(key=...) de M3
print(df["titulo"].value_counts())value_counts es literalmente el Counter de M4 con esteroides: cuenta operaciones (filas), ya ordenado. Ojo al matiz frente al groupby de antes: 168 ventas del Quijote suman 182 unidades — contar filas y sumar unidades son preguntas distintas.
merge: unir con el catálogo
Las ventas no saben el autor ni el precio de lista. El catálogo de M5/M6, sí:
catalogo = pd.DataFrame({
"titulo": ["La Odisea", "Hamlet", "El Quijote", "Fausto"],
"autor": ["Homero", "Shakespeare", "Cervantes", "Goethe"],
"precio": [12.50, 9.95, 15.90, 21.00],
})
catalogo["precio_socio"] = (catalogo["precio"] * 1.04 * 0.95).round(2)
# la fórmula canónica de M3 -> [12.35, 9.83, 15.71, 20.75]
completo = df.merge(catalogo, on="titulo", how="left")
print(completo.groupby("autor")["importe"].sum().round(2).sort_values(ascending=False))autor
Cervantes 2881.98
Homero 1918.90
Goethe 1319.20
Shakespeare 1199.53
Name: importe, dtype: float64merge empareja filas por la columna común (on="titulo"), como el JOIN de las bases de datos que Django hacía por ti en M10. how="left" conserva todas las ventas aunque algún título no estuviera en el catálogo (quedaría con NaN — y isna() te lo chivaría: la limpieza y el merge se vigilan mutuamente).
to_csv: cerrar el círculo
resumen = df.groupby("titulo")["unidades"].sum().sort_values(ascending=False)
resumen.to_csv("informes/unidades_por_titulo.csv")El directorio informes/ de M6 recibe ahora ficheros que nacen de una línea, no de un bucle. Consejo: en DataFrames con índice numérico sin significado, to_csv(..., index=False) evita una columna fantasma Unnamed: 0 al releer.
Errores Comunes y Consejos
SettingWithCopyWarning. Aparece al encadenardf[df["canal"]=="web"]["importe"] = ...: estás escribiendo en una copia y el cambio se pierde. Forma correcta:df.loc[df["canal"]=="web", "importe"] = ...— una sola indexación conloc.- Olvidar
parse_datesy agrupar por texto."2026-04-23" > "2026-04-03"funciona por suerte alfabética, pero.dt.dayofweekno existe en cadenas. Sidf["fecha"].dtypeesobject, vuelve alread_csv. locvsiloctras filtrar. Después deventas_web = df[...],ventas_web.iloc[0]es su primera fila, peroventas_web.loc[0]puede lanzarKeyErrorsi la etiqueta 0 se filtró. Si no necesitas el índice viejo:reset_index(drop=True).NaNcontagioso y silencioso. Cualquier operación conNaNdaNaN, y las agregaciones lo omiten sin avisar (mean()de una columna con huecos usa solo los presentes). Hazdf.isna().sum()nada más cargar, siempre.- Comparar con
== NaN.df["importe"] == np.nanes siempreFalse(NaN no es igual ni a sí mismo). Usaisna()/notna(). - Confundir
value_counts(filas) congroupby(...).sum()(magnitudes). 168 operaciones ≠ 182 unidades. Elige según la pregunta.
Ejercicios
- Calcula el ticket medio (importe medio por operación) de cada canal en
ventas_2026.csv. ¿Confirma la hipótesis del ejercicio 2 de 11-01 sobre por qué difieren tienda y web? Añade la media deunidadespor canal para razonarlo. - ¿Qué mes facturó más (columna
importe)? ¿Y si excluyes las ventas de la semana de Sant Jordi (20 a 26 de abril)? Usa.dt.monthy una máscara con fechas (pd.Timestamp). - Con
mergey el catálogo: crea la columnadescuento_aplicado = precio * unidades - importe(cuánto dejó de cobrar Papyrus en cada venta respecto al precio de lista) y calcula el descuento total acumulado por título. ¿A qué título "le cuesta" más dinero la tarifa de socio?
Soluciones
-
El ticket web es menor, pero las unidades por operación son casi iguales: la diferencia no es que en tienda se compren más ejemplares, sino la mezcla de títulos — la web vende proporcionalmente más Odisea y Hamlet (baratos) y la tienda más Quijote. La hipótesis de 11-01 se corrige con datos: para eso servía el ciclo.print(df.groupby("canal")["importe"].mean().round(2)) # tienda 15.89 # web 13.74 print(df.groupby("canal")["unidades"].mean().round(2)) # tienda 1.07 # web 1.07 -
Abril gana con la semana de Sant Jordi dentro; sin ella, abril se desinfla y gana mayo. Conclusión de negocio: el "buen abril" es en realidad una buena semana — el pedido de stock debe concentrarse ahí, no repartirse por el mes. (print(df.groupby(df["fecha"].dt.month)["importe"].sum().round(2).idxmax()) # 4 fuera_sj = ~df["fecha"].between("2026-04-20", "2026-04-26") print(df[fuera_sj].groupby(df["fecha"].dt.month)["importe"].sum().idxmax()) # 5idxmaxes elargmaxde 11-02, pero devolviendo la etiqueta.) -
El mayor acumulado corresponde a El Quijote: no es el más descontado por unidad, pero es el que más socios compran — volumen manda. Nota fina: el "descuento" por unidad sale sorprendentemente pequeño (0.19 € en el Quijote, no el 5 % que esperarías) porque la tarifa de socio lleva el IVA dentro y el precio de lista no; detectar esa rareza mirando los datos — y preguntar por qué — es exactamente el reflejo que esta lección quería entrenarte.completo = df.merge(catalogo[["titulo", "precio"]], on="titulo", how="left") completo["descuento_aplicado"] = (completo["precio"] * completo["unidades"] - completo["importe"]).round(2) print(completo.groupby("titulo")["descuento_aplicado"].sum().round(2) .sort_values(ascending=False))
Conclusión
pandas ha convertido las preguntas de Ana en respuestas: el sábado es el rey de la semana (130 unidades), la web tiene su propio superventas (La Odisea, 75 frente a las 61 del Quijote), y Fausto es el escaparatista del catálogo — 210 visitas y solo un 11.9 % de conversión, con su precio como principal sospechoso. Por el camino: Series y DataFrames como arrays con nombre, read_csv saldando la promesa de M6, loc/iloc, máscaras heredadas de 11-02, limpieza honesta (495 → 487 filas, documentadas), groupby/value_counts como los acumuladores de M4 con esteroides y merge uniendo ventas con catálogo. Pero estas respuestas son tablas de números, y Ana no va a leer tablas: quiere ver el pico de Sant Jordi, comparar barras de un vistazo, colgar un panel en la trastienda. Convertir números en historias visuales — y no mentir al hacerlo — es Matplotlib, la próxima lección.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
