NumPy et va donar velocitat, però a canvi de números anònims: la fila 2 "era" El Quixot només perquè tu ho recordaves. pandas embolica aquests arrays en dues estructures amb nom — la Series i el DataFrame — i hi afegeix tot el que una anàlisi real necessita: llegir CSV en una línia, dates de veritat, neteja de dades brutes i, sobretot, groupby. Aquesta és la lliçó on per fi responem amb dades les preguntes que l'Anna arrossega des del tancament de M10: quin dia de la setmana ven més, quin títol triomfa a la web i quin es consulta molt però es compra poc.

Contingut

  1. Series i DataFrame: anatomia
  2. read_csv: el DictReader de M6 amb esteroides
  3. Inspecció: head, info, describe, shape
  4. Selecció: columnes, loc/iloc i màscares booleanes
  5. Columnes noves i dates amb .dt
  6. Neteja: isna, fillna, dropna, duplicated
  7. groupby: respondre les preguntes de l'Anna
  8. sort_values i value_counts
  9. merge: unir amb el catàleg
  10. to_csv: desar resultats

Series i DataFrame: anatomia

pip install pandas

Una Series és un array de NumPy amb etiquetes (un índex); un DataFrame és una taula: diverses Series que comparteixen índex, una per columna.

flowchart LR
    subgraph DF["DataFrame df"]
        direction LR
        I["índex<br>0<br>1<br>2"] --- C1["data<br>2026-01-02<br>2026-01-02<br>2026-01-03"] --- C2["titol<br>L'Odissea<br>Hamlet<br>El Quixot"] --- C3["quantia<br>12.50<br>19.90<br>15.71"]
    end
    C3 -.->|"cada columna és<br>una Series<br>(un ndarray + índex)"| S["df['quantia']"]
import pandas as pd    # alies universal, com np

s = pd.Series([12.50, 9.95, 15.90, 21.00],
              index=["L'Odissea", "Hamlet", "El Quixot", "Faust"])
print(s["Faust"])      # 21.0  -> acces per etiqueta, com un dict (M4)
print(s.mean())        # 14.8375 -> matematiques vectoritzades, com NumPy (11-02)

La Series és l'híbrid perfecte entre el dict de M4 (accés per clau) i el ndarray de 11-02 (vectorització). El DataFrame, entre una llista de dicts (les files de DictReader) i una matriu 2D.

read_csv: el DictReader de M6 amb esteroides

A M6 llegies vendes.csv amb csv.DictReader, convertint tipus a mà fila a fila. La promesa era que pandas ho faria "amb esteroides". Aquí tens el bescanvi:

df = pd.read_csv("dades/vendes_2026.csv", parse_dates=["data"])

Una línia i ja està tot: el fitxer obert i tancat (el with de M8 hi va inclòs), la capçalera detectada, unitats com a enter, quantia com a float i — gràcies a parse_datesdata com a data de veritat, no com a text. El que a M6 eren quinze línies de bucle i conversions.

csv.DictReader (M6) pd.read_csv
Itera fila a fila (lazy, memòria mínima) Carrega tot en memòria de cop
Tot és str: converteixes tu Infereix tipus; parse_dates per a les dates
Una pregunta = un bucle Una pregunta = una expressió

L'honestedat de M8 continua vigent: si el fitxer no cap en memòria, el pipeline de generadors guanya. Per a les 487 files de Papyrus (i per a gairebé tot el que càpiga a la RAM, que avui és moltíssim), pandas és l'eina.

Inspecció: el primer pas en carregar qualsevol dada

print(df.shape)     # (487, 5)  -> 487 files, 5 columnes
print(df.head(3))
        data      titol  unitats  quantia   canal
0 2026-01-02  L'Odissea        1    12.50  botiga
1 2026-01-02     Hamlet        2    19.90     web
2 2026-01-03  El Quixot        1    15.71  botiga
df.info()      # tipus i nuls per columna: data datetime64[ns], unitats int64...
print(df["quantia"].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 és l'estadística de 11-01 en una sola crida — i confirma el que hi vam veure: mitjana (15.03) per sobre de la mediana (12.50), amb un màxim de 63.60 € (un pack de Sant Jordi) estirant cap amunt.

Selecció: columnes, loc/iloc i màscares

df["titol"]                 # una columna -> Series
df[["titol", "quantia"]]    # diverses columnes -> DataFrame (ull: llista dins de claudators)

Per a les files hi ha dos accessors, i confondre'ls és l'entrebanc clàssic:

loc iloc
Selecciona per etiqueta de l'índex posició entera
df.loc[3] / df.iloc[3] La fila amb etiqueta 3 La quarta fila, sigui on sigui
Rang [0:3] Inclou l'extrem final Exclou el final (com les llistes)
Ús típic df.loc[mascara, "quantia"] "dona'm les 10 primeres files"

Amb l'índex per defecte (0, 1, 2...) tots dos semblen iguals; així que filtres o reordenes, deixen de ser-ho — l'índex conserva les etiquetes originals.

I l'herència directa de 11-02: les màscares booleanes funcionen idèntiques, amb &, |, ~ i els seus parèntesis:

vendes_web = df[df["canal"] == "web"]
grans_abril = df[(df["quantia"] > 30) & (df["data"].dt.month == 4)]
print(len(vendes_web), len(grans_abril))    # 195 22

Columnes noves i dates amb .dt

Crear una columna és assignar a un nom nou; l'expressió es vectoritza sola:

df["quantia_unitaria"] = (df["quantia"] / df["unitats"]).round(2)

# Es va cobrar a tarifa de soci? (els quatre valors canonics de M3)
df["es_soci"] = df["quantia_unitaria"].isin([12.35, 9.83, 15.71, 20.75])
print(df["es_soci"].sum())    # 141 vendes a socis

Les columnes datetime64 tenen l'accessor .dt amb tot el que a 11-01 fèiem amb date.fromisoformat a mà:

df["mes"] = df["data"].dt.month
df["dia_setmana"] = df["data"].dt.dayofweek    # 0=dilluns ... 6=diumenge
dies = ["dl", "dt", "dc", "dj", "dv", "ds", "dg"]
df["dia_nom"] = df["dia_setmana"].map(dict(enumerate(dies)))

Neteja: el món real arriba brut

El fitxer del mòdul està polit, però el bolcat en brut que l'Anna consolida cada nit — vendes_2026_raw.csv, 495 files — porta els sospitosos habituals de M7: files duplicades per un doble clic a la web i vendes amb la quantia buida per una fallada en desar.

raw = pd.read_csv("dades/vendes_2026_raw.csv", parse_dates=["data"])
print(raw.shape)                      # (495, 5)
print(raw.duplicated().sum())         # 3  -> files EXACTAMENT repetides
print(raw["quantia"].isna().sum())    # 5  -> forats (NaN) a quantia

NaN (Not a Number) és el marcador de "dada absent". Les opcions, amb criteri:

net = raw.drop_duplicates()             # 495 -> 492 files
net = net.dropna(subset=["quantia"])    # 492 -> 487 files
print(net.shape)                        # (487, 5)  -> el nostre vendes_2026.csv

I fillna? Emplenar té sentit quan coneixes el valor correcte (raw["canal"].fillna("botiga") si saps que la fallada només passa a caixa) o un neutre defensable. Inventar-se una quantia mitjana per a una venda concreta, en canvi, contamina els totals: aquí eliminar és més honest que imputar. A M7 aquestes files corruptes llançaven excepcions; ara tenen tractament estadístic. Documenta sempre quantes files treus i per què.

groupby: respondre les preguntes de l'Anna

groupby parteix el DataFrame en grups, aplica una agregació a cadascun i recompon el resultat. És el patró "dict acumulador" de M4 i el Counter en una sola crida. Torn de les preguntes del tancament de M10.

Què es ven més?

print(df.groupby("titol")["unitats"].sum().sort_values(ascending=False))
titol
El Quixot    182
L'Odissea    154
Hamlet       121
Faust         63
Name: unitats, dtype: int64

Quin dia de la setmana ven més? — pregunta 2 de l'ham, resposta:

print(df.groupby("dia_nom")["unitats"].sum().reindex(dies))
dl     48
dt     52
dc     55
dj     60
dv     78
ds    130
dg     97

Dissabte, amb 130 unitats — gairebé el triple que el dilluns. El cap de setmana (ds+dg) concentra el 44 % de les unitats: la Marta ja sap quins dies no pot faltar ningú a la botiga. (El reindex(dies) força l'ordre cronològic; sense ell, pandas ordena alfabèticament.)

Quin títol triomfa a cada canal? — agrupar per dues columnes i pivotar per llegir-ho còmode:

taula = df.groupby(["canal", "titol"])["unitats"].sum().unstack()
print(taula)
titol   El Quixot  Faust  Hamlet  L'Odissea
canal
botiga        121     38      74         79
web            61     25      47         75

A la botiga mana El Quixot (121); a la web, sorpresa: L'Odissea (75) supera el Quixot (61). Hipòtesi de l'Anna: a la web hi pesa el públic del club de lectura de la Júlia. Sigui quina sigui la causa, l'aparador de la home de Flask ja sap quina portada posar.

Què es consulta i no es compra? — pregunta 1 de l'ham. Les vendes soles no basten (ho vam preveure a 11-01): calen les visites a /llibre/<titol> dels logs de M10:

visites = pd.Series({"El Quixot": 260, "L'Odissea": 220,
                     "Faust": 210, "Hamlet": 150}, name="visites_web")
compres_web = df[df["canal"] == "web"].groupby("titol")["unitats"].sum()
print((compres_web / visites * 100).round(1).sort_values())
Faust        11.9
El Quixot    23.5
Hamlet       31.3
L'Odissea    34.1
dtype: float64

Aquí ho tens: Faust es mira molt (210 visites, tercer del catàleg) però només l'11.9 % de les visites acaben en compra — un terç de la conversió dels altres. Amb el seu preu de 21.00 €, la hipòtesi del preu s'imposa sola. Dada, no opinió: exactament el que l'Anna demanava.

sort_values i value_counts

df.sort_values("quantia", ascending=False).head(3)       # les 3 vendes mes grans
df.sort_values(["data", "quantia"])                      # multiclau, com sorted(key=...) de M3

print(df["titol"].value_counts())
titol
El Quixot    168
L'Odissea    145
Hamlet       116
Faust         58
Name: count, dtype: int64

value_counts és literalment el Counter de M4 amb esteroides: compta operacions (files), ja ordenat. Ull al matís respecte al groupby d'abans: 168 vendes del Quixot sumen 182 unitats — comptar files i sumar unitats són preguntes diferents.

merge: unir amb el catàleg

Les vendes no saben l'autor ni el preu de llista. El catàleg de M5/M6, sí:

cataleg = pd.DataFrame({
    "titol": ["L'Odissea", "Hamlet", "El Quixot", "Faust"],
    "autor": ["Homer", "Shakespeare", "Cervantes", "Goethe"],
    "preu":  [12.50, 9.95, 15.90, 21.00],
})
cataleg["preu_soci"] = (cataleg["preu"] * 1.04 * 0.95).round(2)
# la formula canonica de M3 -> [12.35, 9.83, 15.71, 20.75]

complet = df.merge(cataleg, on="titol", how="left")
print(complet.groupby("autor")["quantia"].sum().round(2).sort_values(ascending=False))
autor
Cervantes      2881.98
Homer          1918.90
Goethe         1319.20
Shakespeare    1199.53
Name: quantia, dtype: float64

merge emparella files per la columna comuna (on="titol"), com el JOIN de les bases de dades que Django feia per tu a M10. how="left" conserva totes les vendes encara que algun títol no fos al catàleg (quedaria amb NaN — i isna() t'ho xivaria: la neteja i el merge es vigilen mútuament).

to_csv: tancar el cercle

resum = df.groupby("titol")["unitats"].sum().sort_values(ascending=False)
resum.to_csv("informes/unitats_per_titol.csv")

El directori informes/ de M6 rep ara fitxers que neixen d'una línia, no d'un bucle. Consell: en DataFrames amb índex numèric sense significat, to_csv(..., index=False) evita una columna fantasma Unnamed: 0 en rellegir.

Errors Comuns i Consells

  • SettingWithCopyWarning. Apareix en encadenar df[df["canal"]=="web"]["quantia"] = ...: estàs escrivint en una còpia i el canvi es perd. Forma correcta: df.loc[df["canal"]=="web", "quantia"] = ... — una sola indexació amb loc.
  • Oblidar parse_dates i agrupar per text. "2026-04-23" > "2026-04-03" funciona per sort alfabètica, però .dt.dayofweek no existeix en cadenes. Si df["data"].dtype és object, torna al read_csv.
  • loc vs iloc després de filtrar. Després de vendes_web = df[...], vendes_web.iloc[0] és la seva primera fila, però vendes_web.loc[0] pot llançar KeyError si l'etiqueta 0 s'ha filtrat. Si no necessites l'índex vell: reset_index(drop=True).
  • NaN contagiós i silenciós. Qualsevol operació amb NaN dona NaN, i les agregacions l'ometen sense avisar (el mean() d'una columna amb forats usa només els presents). Fes df.isna().sum() només carregar, sempre.
  • Comparar amb == NaN. df["quantia"] == np.nan és sempre False (NaN no és igual ni a si mateix). Usa isna() / notna().
  • Confondre value_counts (files) amb groupby(...).sum() (magnituds). 168 operacions ≠ 182 unitats. Tria segons la pregunta.

Exercicis

  1. Calcula el tiquet mitjà (quantia mitjana per operació) de cada canal a vendes_2026.csv. Confirma la hipòtesi de l'exercici 2 de 11-01 sobre per què difereixen botiga i web? Afegeix la mitjana d'unitats per canal per raonar-ho.
  2. Quin mes va facturar més (columna quantia)? I si excloguessis les vendes de la setmana de Sant Jordi (del 20 al 26 d'abril)? Usa .dt.month i una màscara amb dates (pd.Timestamp).
  3. Amb merge i el catàleg: crea la columna descompte_aplicat = preu * unitats - quantia (quant va deixar de cobrar Papyrus en cada venda respecte al preu de llista) i calcula el descompte total acumulat per títol. A quin títol "li costa" més diners la tarifa de soci?

Solucions

  1. print(df.groupby("canal")["quantia"].mean().round(2))
    # botiga    15.89
    # web       13.74
    print(df.groupby("canal")["unitats"].mean().round(2))
    # botiga    1.07
    # web       1.07
    
    El tiquet web és menor, però les unitats per operació són gairebé iguals: la diferència no és que a la botiga es comprin més exemplars, sinó la barreja de títols — la web ven proporcionalment més Odissea i Hamlet (barats) i la botiga més Quixot. La hipòtesi de 11-01 es corregeix amb dades: per a això servia el cicle.
  2. print(df.groupby(df["data"].dt.month)["quantia"].sum().round(2).idxmax())   # 4
    fora_sj = ~df["data"].between("2026-04-20", "2026-04-26")
    print(df[fora_sj].groupby(df["data"].dt.month)["quantia"].sum().idxmax())   # 5
    
    L'abril guanya amb la setmana de Sant Jordi a dins; sense ella, l'abril es desinfla i guanya el maig. Conclusió de negoci: el "bon abril" és en realitat una bona setmana — la comanda d'estoc s'ha de concentrar allà, no repartir-se pel mes. (idxmax és l'argmax de 11-02, però retornant l'etiqueta.)
  3. complet = df.merge(cataleg[["titol", "preu"]], on="titol", how="left")
    complet["descompte_aplicat"] = (complet["preu"] * complet["unitats"]
                                    - complet["quantia"]).round(2)
    print(complet.groupby("titol")["descompte_aplicat"].sum().round(2)
                 .sort_values(ascending=False))
    
    L'acumulat més gran correspon a El Quixot: no és el més descomptat per unitat, però és el que més socis compren — el volum mana. Nota fina: el "descompte" per unitat surt sorprenentment petit (0.19 € en el Quixot, no el 5 % que esperaries) perquè la tarifa de soci porta l'IVA a dins i el preu de llista no; detectar aquesta raresa mirant les dades — i preguntar per què — és exactament el reflex que aquesta lliçó et volia entrenar.

Conclusió

pandas ha convertit les preguntes de l'Anna en respostes: el dissabte és el rei de la setmana (130 unitats), la web té el seu propi supervendes (L'Odissea, 75 davant les 61 del Quixot), i Faust és l'aparadorista del catàleg — 210 visites i només un 11.9 % de conversió, amb el seu preu com a principal sospitós. Pel camí: Series i DataFrames com a arrays amb nom, read_csv saldant la promesa de M6, loc/iloc, màscares heretades de 11-02, neteja honesta (495 → 487 files, documentades), groupby/value_counts com els acumuladors de M4 amb esteroides i merge unint vendes amb catàleg. Però aquestes respostes són taules de números, i l'Anna no llegirà taules: vol veure el pic de Sant Jordi, comparar barres d'un cop d'ull, penjar un panell a la rebotiga. Convertir números en històries visuals — i no mentir en fer-ho — és Matplotlib, la propera lliçó.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats