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

  1. Series y DataFrame: anatomía
  2. read_csv: el DictReader de M6 con esteroides
  3. Inspección: head, info, describe, shape
  4. Selección: columnas, loc/iloc y máscaras booleanas
  5. Columnas nuevas y fechas con .dt
  6. Limpieza: isna, fillna, dropna, duplicated
  7. groupby: responder las preguntas de Ana
  8. sort_values y value_counts
  9. merge: unir con el catálogo
  10. to_csv: guardar resultados

Series y DataFrame: anatomía

pip install pandas

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:

df = pd.read_csv("datos/ventas_2026.csv", parse_dates=["fecha"])

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_datesfecha 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

print(df.shape)     # (487, 5)  -> 487 filas, 5 columnas
print(df.head(3))
       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  tienda
df.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.600000

describe 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 22

Columnas 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 socios

Las 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 importe

NaN (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?

print(df.groupby("titulo")["unidades"].sum().sort_values(ascending=False))
titulo
El Quijote    182
La Odisea     154
Hamlet        121
Fausto         63
Name: unidades, dtype: int64

¿Qué día de la semana vende más? — pregunta 2 del gancho, respondida:

print(df.groupby("dia_nombre")["unidades"].sum().reindex(dias))
lun     48
mar     52
mié     55
jue     60
vie     78
sáb    130
dom     97

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:

tabla = df.groupby(["canal", "titulo"])["unidades"].sum().unstack()
print(tabla)
titulo  El Quijote  Fausto  Hamlet  La Odisea
canal
tienda         121      38      74         79
web             61      25      47         75

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())
Fausto        11.9
El Quijote    23.5
Hamlet        31.3
La Odisea     34.1
dtype: float64

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())
titulo
El Quijote    168
La Odisea     145
Hamlet        116
Fausto         58
Name: count, dtype: int64

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: float64

merge 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 encadenar df[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 con loc.
  • Olvidar parse_dates y agrupar por texto. "2026-04-23" > "2026-04-03" funciona por suerte alfabética, pero .dt.dayofweek no existe en cadenas. Si df["fecha"].dtype es object, vuelve al read_csv.
  • loc vs iloc tras filtrar. Después de ventas_web = df[...], ventas_web.iloc[0] es su primera fila, pero ventas_web.loc[0] puede lanzar KeyError si la etiqueta 0 se filtró. Si no necesitas el índice viejo: reset_index(drop=True).
  • NaN contagioso y silencioso. Cualquier operación con NaN da NaN, y las agregaciones lo omiten sin avisar (mean() de una columna con huecos usa solo los presentes). Haz df.isna().sum() nada más cargar, siempre.
  • Comparar con == NaN. df["importe"] == np.nan es siempre False (NaN no es igual ni a sí mismo). Usa isna() / notna().
  • Confundir value_counts (filas) con groupby(...).sum() (magnitudes). 168 operaciones ≠ 182 unidades. Elige según la pregunta.

Ejercicios

  1. 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 de unidades por canal para razonarlo.
  2. ¿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.month y una máscara con fechas (pd.Timestamp).
  3. Con merge y el catálogo: crea la columna descuento_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

  1. 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
    
    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.
  2. 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())  # 5
    
    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. (idxmax es el argmax de 11-02, pero devolviendo la etiqueta.)
  3. 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))
    
    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.

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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados