A 07-01 vam decidir que el taller de la Marta es munta sobre Python i vam comprovar amb un benchmark que la seva velocitat ve de delegar el càlcul en llibreries natives. Les tres primeres d'aquestes llibreries, les que sostenen tota la resta, són les que al mòdul 4 vam utilitzar sense aturar-nos-hi: NumPy, que aporta l'array numèric i les operacions vectoritzades; pandas, que aporta la taula amb noms de columna, dates i agrupacions; i Matplotlib, que dibuixa. generar_comandes_ml era NumPy més pandas; groupby("categoria")["retornat"].mean() era pandas; les corbes d'aprenentatge de 04-06 eren Matplotlib. Aquesta lliçó les explica de manera sistemàtica i amb un exemple continu: les comandes i la demanda setmanal de NovaMarket que ja coneixes. Veurem l'ndarray, la seva forma i tipus, la indexació, el broadcasting i les màscares booleanes; Series i DataFrame, lectura i escriptura de CSV, selecció amb loc/iloc, valors absents, groupby, merge, dates i remostreig; i figures amb histogrames, barres, línies i subgràfics, que descriurem en text. Acabarem amb una taula "operació → com es fa" que serveix de xuleta per al mòdul 8. És important perquè qualsevol projecte d'IA dedica més temps a preparar i mirar dades amb aquestes tres llibreries que a cridar fit. El que fan scikit-learn i PyTorch amb aquests arrays és assumpte de 07-03.

Contingut

  1. NumPy: l'ndarray, forma i dtype
  2. Creació, indexació i slicing
  3. Operacions vectoritzades i broadcasting
  4. Agregacions per eix, màscares booleanes i aleatorietat
  5. Àlgebra lineal bàsica i per què NumPy és la base de tot
  6. pandas: Series i DataFrame, lectura, escriptura i inspecció
  7. Selecció i filtratge: loc, iloc i màscares
  8. Valors absents, groupby i agregacions
  9. Unir taules amb merge
  10. Dates: to_datetime, .dt, resample i rolling
  11. apply amb moderació
  12. Matplotlib: figura, eixos i els quatre gràfics bàsics
  13. Taula "operació → com es fa"
  14. Errors Comuns i Consells
  15. Exercicis
  16. Conclusió

  1. NumPy: l'ndarray, forma i dtype

Una llista de Python pot contenir qualsevol cosa ([1, "hola", 2.5]), i per això cada element és un objecte separat amb el seu tipus, el seu comptador de referències i la seva adreça de memòria: flexible i lent. L'ndarray de NumPy és el contrari: un bloc contigu de memòria en què tots els elements tenen el mateix tipus (dtype) i una forma (shape) que diu quantes dimensions té i quants elements per dimensió. Aquest és el secret del benchmark de 07-01: la CPU recorre el bloc sense preguntar res.

import numpy as np

imports = np.array([129.90, 45.50, 899.00, 19.99, 249.00])   # 1 dimensió: un vector
print(imports, imports.dtype, imports.shape, imports.ndim)
# [129.9   45.5  899.    19.99 249.  ] float64 (5,) 1

taula = np.array([[129.90, 2, 3],        # 2 dimensions: una matriu de 4 files x 3 columnes
                  [45.50, 1, 5],         # (import_comanda, num_articles, dies_lliurament)
                  [899.00, 1, 2],
                  [19.99, 3, 7]])
print(taula.shape, taula.dtype)         # (4, 3) float64  <- tots float, encara que hi hagi enters
print(np.array([1, 2, 3]).dtype)        # int64
print(np.array([1, 2.5]).dtype)         # float64: NumPy promociona al tipus més general
  • shape és una tupla: (5,) és un vector de 5; (4, 3) una matriu de 4 × 3; (3000, 21) va ser la matriu de característiques de 04-03 (3.000 comandes, 21 columnes després del ColumnTransformer); una imatge 16 × 16 a 05-04 era (16, 16) i un lot d'imatges (n, 1, 16, 16).
  • dtype és el tipus comú: float64 (8 bytes, l'habitual), float32 (4 bytes, el que fa servir PyTorch per defecte), int64, bool. Un array d'un milió de float64 ocupa 8 MB exactes; una llista Python equivalent, unes quatre vegades més.
  • ndim és el nombre de dimensions (eixos). En el vocabulari de la IA, un array d'1 dimensió és un vector, de 2 una matriu i de més un tensor.

  1. Creació, indexació i slicing

Maneres de crear arrays que veuràs contínuament:

np.zeros(3)                 # [0. 0. 0.]
np.ones((2, 3))             # matriu 2x3 d'uns
np.full(3, 7.5)             # [7.5 7.5 7.5]
np.arange(0, 10, 2)         # [0 2 4 6 8]         com range, però retorna array
np.linspace(0, 1, 5)        # [0.   0.25 0.5  0.75 1.  ]  5 punts equiespaiats
np.eye(3)                   # matriu identitat 3x3

Indexar i trossejar funciona com a les llistes, però amb una posició per eix separada per comes, i amb la regla que un tros (slice) és una vista, no una còpia:

taula[0]          # [129.9   2.    3. ]   primera fila
taula[0, 0]       # 129.9                 fila 0, columna 0
taula[:, 0]       # [129.9   45.5  899.    19.99]   TOTES les files, columna 0 (la dels imports)
taula[1:3]        # files 1 i 2 (la 3 no hi entra), totes les columnes
taula[-1, -1]     # 7.0                   última fila, última columna

vista = taula[:, 0]       # vista de la columna d'imports
vista[1] = 0.0            # modifica taula[1, 0] també!
copia = taula[:, 0].copy()   # si vols independència, copia explícitament

Que un slice sigui una vista és una decisió de disseny per eficiència (no es copien megabytes en trossejar), i una font clàssica d'ensurts: si has de modificar el tros, fes servir .copy().

  1. Operacions vectoritzades i broadcasting

Els operadors aritmètics actuen element a element sobre tot l'array, sense bucle:

imports = np.array([129.90, 45.50, 899.00, 19.99, 249.00])
np.round(imports * 1.21, 2)      # [ 157.18   55.06 1087.79   24.19  301.29]  amb IVA
imports - 5                      # [124.9   40.5  894.    14.99 244.  ]        despeses d'enviament fora
descomptes = np.array([0.10, 0.0, 0.15, 0.0, 0.05])
np.round(imports * (1 - descomptes), 2)   # [116.91  45.5  764.15  19.99 236.55]  array per array
np.log(imports).round(3)         # [4.867 3.818 6.801 2.995 5.517]  funcions "universals" (ufuncs)

imports * 1.21 combina un array de forma (5,) amb un escalar: NumPy "estira" l'escalar fins a la forma de l'array. Aquesta regla generalitzada es diu broadcasting: dos arrays poden operar si, comparant les seves formes de dreta a esquerra, cada parell de dimensions és igual o una d'elles val 1 (o falta). Exemple numèric, l'operació més comuna en ML, estandarditzar columnes (restar la mitjana de cada columna i dividir per la seva desviació; és el que fa StandardScaler a 04-03):

taula = np.array([[129.90, 2, 3], [45.50, 1, 5], [899.00, 1, 2], [19.99, 3, 7]])   # (4, 3)
mitjana = taula.mean(axis=0)   # (3,)  [273.5975   1.75     4.25  ]  una mitjana per columna
desv = taula.std(axis=0)       # (3,)  [363.36     0.829    1.920]
z = (taula - mitjana) / desv   # (4,3) - (3,) -> la fila (3,) es resta a CADA fila de taula
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): taula de sumes
# [[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,)

Comprovació de la regla: (4, 3) amb (3,): comparant per la dreta, 3 = 3 i l'altra dimensió falta, compatible. (4, 1) amb (3,): 1 davant de 3 (val, un dels dos és 1), 4 davant de res: resultat (4, 3). (3,) amb (2,): 3 ≠ 2 i cap no és 1: error. Quan un càlcul de NumPy o PyTorch et doni un error de formes, aquest és el joc que cal jugar.

  1. Agregacions per eix, màscares booleanes i aleatorietat

Agregar és reduir un eix: sum, mean, std, min, max, argmax (posició del màxim). Sense axis ho redueixen tot; amb axis=0 recorren les files i retornen un valor per columna; amb axis=1, un valor per fila:

taula.sum()            # 1118.39                     tot
taula.sum(axis=0)      # [1094.39    7.     17.  ]    per columna: import total, articles, dies
taula.sum(axis=1)      # [134.9   51.5  902.    29.99]  per fila (sense sentit aquí, però il·lustra l'eix)
taula.max(axis=0)      # [899.   3.   7.]
taula.argmax(axis=0)   # [2 3 3]                     a quina fila hi ha el màxim de cada columna

Regla mnemotècnica: axis és l'eix que desapareix. Amb axis=0 desapareixen les files i queda una xifra per columna.

Les màscares booleanes són la manera vectoritzada de filtrar i de comptar, i les hem fet servir sense anomenar-les des del mòdul 4 (rng.random(n) < 0.30 per decidir quins clients són nous):

import_comanda = np.array([129.90, 45.50, 899.00, 19.99, 249.00, 75.0])
retornat = np.array([0, 0, 1, 0, 1, 0])
import_comanda > 100                       # [ True False  True False  True False]  array de bool
import_comanda[import_comanda > 100]       # [129.9 899.  249. ]   filtra
(import_comanda > 100).sum()               # 3      True val 1: compta quants ho compleixen
(import_comanda > 100).mean()              # 0.5    proporció que ho compleix
retornat[import_comanda > 100].mean()      # 0.667  taxa de devolució de les comandes cares
retornat[import_comanda <= 100].mean()     # 0.0    i de les barates
np.where(import_comanda > 100, "alt", "baix")   # ['alt' 'baix' 'alt' 'baix' 'alt' 'baix']  condicional vectoritzat
import_comanda[(import_comanda > 40) & (retornat == 0)]  # [129.9  45.5  75. ]  combinar amb & (i), | (o), ~ (no)

Ull amb els parèntesis: & té més prioritat que >, així que import_comanda > 40 & retornat == 0 sense parèntesis falla.

Aleatorietat reproduïble: des de NumPy 1.17 la manera recomanada és crear un generador amb llavor, np.random.default_rng(llavor), i demanar-li números. És el que fa generar_comandes_ml i per això les 3.000 comandes són les mateixes a tots els mòduls:

rng = np.random.default_rng(42)
rng.random(3)                     # [0.774 0.439 0.859]   uniformes a [0, 1)
rng.integers(1, 6, size=5)        # [1 4 2 1 3]           enters d'1 a 5 (el 6 no hi entra)
rng.normal(0, 25, 3).round(1)     # [ 3.2 -7.9 -0.4]      normals de mitjana 0 i desviació 25 (el soroll de la demanda)
rng.choice(["A", "B", "C"], size=6, p=[0.4, 0.35, 0.25])   # ['B' 'A' 'C' 'B' 'C' 'B']  zones
np.random.default_rng(42).random(3)   # [0.774 0.439 0.859]  mateixa llavor, mateixos números

  1. Àlgebra lineal bàsica i per què NumPy és la base de tot

L'operador @ és el producte matricial, i amb ell s'escriu en una línia el que la regressió logística de 04-04 i cada capa nn.Linear de 05-02 fan per dins: z = X @ w + b:

X = np.array([[1.0, 129.90, 2],      # 3 comandes x 3 característiques (la primera és el biaix)
              [1.0, 45.50, 1],
              [1.0, 899.00, 1]])
w = np.array([-3.4, 0.01, 0.35])     # pesos (inventats, de l'estil de la "veritat oculta" de 04-01)
z = X @ w                            # (3,3) @ (3,) -> (3,)   un logit per comanda
print(z, 1 / (1 + np.exp(-z)))
# [-1.401 -2.595  5.94 ]  [0.198 0.069 0.997]   sigmoide -> probabilitat de devolució
X.T                                  # transposada, (3,3) aquí; d'una (3000,21) seria (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]   resol A x = b (millor que inv(A) @ b)

np.linalg té a més inv, det, eig (valors propis, base del PCA de 04-02), svd i norm. No cal dominar l'àlgebra per seguir el curs; sí que cal saber que tot model és al final una seqüència de @ i funcions element a element sobre arrays.

Per això NumPy és la base: scikit-learn rep i retorna arrays (o DataFrames que converteix en arrays), Matplotlib dibuixa arrays, pandas desa cada columna en un array, i els tensors de PyTorch de 05-03 són "arrays amb gradient": mateix concepte de forma, dtype i broadcasting, més requires_grad perquè autograd apunti les operacions, i torch.tensor(array) / tensor.numpy() per passar de l'un a l'altre sense copiar quan el tipus ho permet. Qui entén NumPy té el 80 % de PyTorch.

  1. pandas: Series i DataFrame, lectura, escriptura i inspecció

NumPy no sap que la columna 0 és "import" ni entén de dates ni de valors absents en enters. pandas posa sobre els arrays dues estructures amb etiquetes:

  • Series: un array unidimensional amb un índex (etiquetes de fila) i un nom.
  • DataFrame: una taula de columnes, cadascuna una Series (possiblement de dtype diferent), que comparteixen índex. És el que retorna generar_comandes_ml.
import pandas as pd
from novamarket_ml import generar_comandes_ml, generar_demanda_setmanal   # generadors del mòdul 4

s = pd.Series([129.90, 45.50, 899.00], index=["P1001", "P1002", "P1003"], name="import_comanda")
print(s["P1002"], s.mean(), (s > 100).sum())      # 45.5 358.13 2   accés per etiqueta; NumPy per sota

comandes = generar_comandes_ml(3000, 42)           # DataFrame de 3000 x 7
print(comandes.shape)                              # (3000, 7)
print(comandes.head())                             # primeres 5 files
comandes.info()                                    # columnes, no nuls, dtypes, memòria
print(comandes.describe().round(2))                # estadístics de les numèriques
print(comandes["categoria"].value_counts())        # recompte per valor

Sortida (resumida) d'info() i describe():

RangeIndex: 3000 entries, 0 to 2999
Data columns (total 7 columns):
 #   Column            Non-Null Count  Dtype
 0   import_comanda    3000 non-null   float64
 1   num_articles      3000 non-null   int64
 2   dies_lliurament   3000 non-null   int64
 3   client_nou        3000 non-null   int64
 4   categoria         3000 non-null   str
 5   codi_postal_zona  3000 non-null   str
 6   retornat          3000 non-null   int64
memory usage: 164.2 KB

       import_comanda  num_articles  dies_lliurament  client_nou  retornat
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
llar            908
informatica     615
accessoris      439

Ja reconeixes les xifres: 30 % de clients nous, taxa de devolució 0,16 (16,4 %), import mitjà 125 € amb mediana 108 (cua llarga: la gamma del generador). Les columnes de text apareixen com a str a les versions recents de pandas (object a les anteriors). head(), info(), describe() i value_counts() són el primer que cal executar sobre qualsevol fitxer nou, abans de pensar en models.

CSV d'anada i tornada, que és com circularan comandes.csv i companyia per NovaMarket:

comandes.to_csv("comandes.csv", index=False)   # index=False: no desis el 0..2999 com a columna
c2 = pd.read_csv("comandes.csv")               # infereix tipus; parse_dates=[...] per a columnes de data
print(c2.shape)                                # (3000, 7)
# Primeres línies del fitxer:
# import_comanda,num_articles,dies_lliurament,client_nou,categoria,codi_postal_zona,retornat
# 130.51,4,4,1,llar,B,0

read_csv accepta sep=";" (els CSV d'Excel en configuració regional europea), decimal=",", encoding="latin-1", usecols, nrows; i hi ha read_excel, read_sql (per a la consulta de 07-01 directament a un DataFrame), read_parquet (format columnar comprimit, molt més ràpid que CSV per a taules grans) i read_json.

  1. Selecció i filtratge: loc, iloc i màscares

Tres maneres de seleccionar, i convé distingir-les:

comandes["import_comanda"]                          # una columna -> Series
comandes[["import_comanda", "categoria"]]           # diverses columnes -> DataFrame (llista dins de claudàtors)
comandes.loc[0]                                     # fila amb ETIQUETA 0 -> Series amb una entrada per columna
comandes.loc[0:2, ["import_comanda", "retornat"]]   # etiquetes 0,1,2 (loc INCLOU el final) i dues columnes
comandes.iloc[0:2, 0:3]                             # POSICIONS: files 0-1, columnes 0-2 (iloc exclou el final)
comandes.iloc[-1, 0]                                # 94.06   última comanda, primera columna

cars = comandes[comandes["import_comanda"] > 300]                       # màscara booleana, com a NumPy
print(len(cars), cars["retornat"].mean().round(3))                      # 134 0.612  <- el 61 % de les cares es retorna
sel = comandes[(comandes["categoria"] == "electronica") & (comandes["client_nou"] == 1)]
print(len(sel), sel["retornat"].mean().round(3))                        # 316 0.415  <- la fila de l'exercici 3 de 07-01
comandes.query("import_comanda > 300 and categoria == 'llar'").shape    # (37, 7)   la mateixa idea, com a text
comandes.sort_values("import_comanda", ascending=False).head(3)         # les tres comandes més cares (632, 608, 578 €; les tres retornades)
  • loc va per etiquetes (les de l'índex i els noms de columna) i inclou l'extrem; iloc va per posicions enteres i l'exclou, com Python. Mentre l'índex sigui el RangeIndex 0..n-1 coincideixen, i per això la confusió passa desapercebuda fins que filtres o reordenes i les etiquetes deixen de ser posicions.
  • Per assignar sobre una selecció fes servir df.loc[mascara, "columna"] = valor; assignar sobre df[mascara]["columna"] pot modificar una còpia i no l'original (el famós SettingWithCopyWarning).

Crear columnes noves és assignar, i pd.cut discretitza en trams (recorda 04-03):

comandes["import_per_article"] = (comandes["import_comanda"] / comandes["num_articles"]).round(2)
comandes["tram"] = pd.cut(comandes["import_comanda"], bins=[0, 50, 150, 400, np.inf],
                          labels=["<50", "50-150", "150-400", ">400"])
print(comandes.groupby("tram", observed=True)["retornat"].mean().round(3))
# <50 0.049   50-150 0.115   150-400 0.300   >400 0.743

La taxa de devolució es multiplica per 15 entre el tram més barat i el més car: el +0.010 * (import_comanda - 100) del generador vist des de les dades.

  1. Valors absents, groupby i agregacions

A 04-03 vam embrutar les comandes amb NaN i els vam imputar dins del pipeline. Les eines de pandas per veure'ls i tractar-los:

rng = np.random.default_rng(7)
brut = comandes.copy()
brut.loc[rng.random(len(brut)) < 0.05, "dies_lliurament"] = np.nan   # 5 % de lliuraments sense dada
brut.loc[rng.random(len(brut)) < 0.02, "import_comanda"] = np.nan
print(brut.isna().sum())                        # import_comanda 73, dies_lliurament 142, resta 0
print(brut["import_comanda"].mean().round(2))   # 124.79  les agregacions IGNOREN NaN per defecte
print(brut.dropna().shape)                      # (2787, 7)  eliminar files amb algun NaN: se'n perden 213
omplert = brut.fillna({"dies_lliurament": brut["dies_lliurament"].median(),
                       "import_comanda": brut["import_comanda"].median()})   # imputar per mediana
print(omplert.isna().sum().sum())               # 0
print(brut["dies_lliurament"].dtype)            # float64: en ficar NaN en una columna d'enters, pandas la passa a float

isna()/notna(), dropna(), fillna(), i en models el SimpleImputer de 04-03 (que a més recorda la mediana d'entrenament per aplicar-la en producció, cosa que fillna a mà no fa).

groupby és l'operació central de l'anàlisi: dividir en grups, aplicar una agregació a cadascun i combinar. És el GROUP BY d'SQL de 07-01, i amb ell responem les preguntes del Diego:

comandes.groupby("categoria")["retornat"].mean().round(3)
# accessoris 0.130  electronica 0.207  informatica 0.159  llar 0.135   (mateixa xifra que la consulta SQL)

taxa = comandes.groupby("categoria")["retornat"].agg(comandes="count", retornats="sum", taxa="mean").round(3)
print(taxa.sort_values("taxa", ascending=False))
#              comandes  retornats   taxa
# electronica      1038        215  0.207
# informatica       615         98  0.159
# llar              908        123  0.135
# accessoris        439         57  0.130

# Dues claus -> índex jeràrquic; unstack() porta la segona clau a columnes
comandes.groupby(["categoria", "codi_postal_zona"])["retornat"].mean().round(3).unstack()
# codi_postal_zona        A      B      C
# accessoris            0.128  0.123  0.140
# electronica           0.223  0.187  0.207
# informatica           0.139  0.178  0.165
# llar                  0.121  0.162  0.117

comandes.groupby("client_nou").agg(import_mitja=("import_comanda", "mean"),
                                   taxa_dev=("retornat", "mean"),
                                   comandes=("import_comanda", "size")).round(3)
#             import_mitja  taxa_dev  comandes
# client_nou
# 0                124.813     0.091      2094
# 1                126.397     0.333       906

Lectures: per zona la taxa no varia de manera sistemàtica (0,12-0,22 sense patró per columna): coherent amb el fet que codi_postal_zona no influeix en el generador, i amb el fet que a 04-04 el model li va donar pesos gairebé nuls. Els clients nous gasten gairebé el mateix de mitjana (126 € davant de 125 €) però retornen 3,7 vegades més (33 % davant de 9 %). La sintaxi agg(nom=("columna", "funcio")) (agregació amb nom) és la més llegible; pd.crosstab(comandes["categoria"], comandes["retornat"], normalize="index") dona la mateixa taula de taxes en format de contingència.

  1. Unir taules amb merge

Les dades de NovaMarket estan repartides: comandes.csv no diu quant costa gestionar una devolució ni des de quin magatzem surt. Això és a productes.csv. Construïm una taula petita per categoria (a la realitat seria per producte) i la unim:

productes = pd.DataFrame({
    "categoria": ["electronica", "llar", "informatica", "accessoris"],
    "producte_estrella": ["auriculars NovaSound", "robot aspirador NovaClean",
                          "portatil NovaBook", "funda NovaCase"],
    "cost_devolucio": [12.0, 18.0, 25.0, 4.0],        # euros per devolució gestionada
    "magatzem": ["Getafe", "Zaragoza", "Getafe", "Zaragoza"],
})
unit = comandes.merge(productes, on="categoria", how="left")   # clau: categoria; left: conserva totes les comandes
print(unit.shape)                                             # (3000, 10): 7 columnes + 3 de noves
unit["cost_dev"] = unit["retornat"] * unit["cost_devolucio"]
print(unit.groupby("magatzem").agg(comandes=("import_comanda", "size"), retornats=("retornat", "sum"),
                                   cost=("cost_dev", "sum")))
#           comandes  retornats    cost
# Getafe        1653        313  5030.0
# Zaragoza      1347        180  2442.0
  • on és la columna clau (si es diu diferent a cada taula, left_on/right_on); how decideix què passa amb les claus sense parella: left conserva totes les files de l'esquerra (comandes) i posa NaN on no hi ha producte; inner només les que encaixen a totes dues; outer totes. Si treus "accessoris" de productes, inner deixa 2.561 comandes i left en deixa 3.000 amb 439 magatzem a NaN.
  • join és el mateix per índex; pd.concat([df1, df2]) apila taules (files o columnes) sense clau.
  • El Diego té la seva xifra: les devolucions costen uns 7.500 € en aquestes 3.000 comandes, dos terços a Getafe (electrònica i informàtica). És el tipus de dada que converteix "AUC 0,844" en "euros que s'estalvien".

  1. Dates: to_datetime, .dt, resample i rolling

La demanda setmanal del NovaClean (generar_demanda_setmanal, 04-02) porta una columna data de tipus datetime64. pandas converteix text a data amb pd.to_datetime i n'exposa les parts amb .dt:

demanda = generar_demanda_setmanal(104, 42)      # 104 setmanes: 2024 i 2025, dilluns
print(demanda.dtypes)                            # setmana int64, data datetime64, unitats int64
pd.to_datetime("24/11/2025", dayfirst=True)     # Timestamp('2025-11-24')  compte amb el format dia/mes/any
demanda["data"].dt.year.value_counts().sort_index()   # 2024: 53 setmanes, 2025: 51
demanda["mes"] = demanda["data"].dt.month        # també .dt.day, .dt.dayofweek, .dt.day_name(), .dt.quarter
demanda[demanda["data"] >= "2025-11-01"].head(4)       # comparar amb text funciona
#     setmana       data  unitats
# 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

# Remostrejar de setmanal a mensual: l'índex ha de ser la data
mensual = demanda.set_index("data")["unitats"].resample("MS").sum()   # MS = inici 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 mesos; màxim juny 2025: 3268

# Mitjana mòbil: suavitza el soroll setmanal
demanda["mitjana_mobil_4"] = demanda["unitats"].rolling(4).mean()   # NaN a les 3 primeres

resample accepta "W", "MS", "QS" (trimestre), "YS", i qualsevol agregació (sum, mean, max); rolling(k) calcula finestres lliscants (center=True per centrar-les). Compte amb la trampa clàssica de les mensualitats: un mes amb cinc dilluns suma cinc setmanes i un altre quatre; per comparar mesos seriosament cal normalitzar per setmanes o treballar amb dies.

  1. apply amb moderació

apply executa una funció Python fila a fila o valor a valor. És còmode i és un bucle Python disfressat (07-01): fes-lo servir quan no hi hagi alternativa vectoritzada, i prefereix map per a diccionaris, .str per a text i pd.cut/np.where per a trams i condicionals:

def tram(imp):
    return "baix" if imp < 50 else ("mitja" if imp < 150 else "alt")
a = comandes["import_comanda"].apply(tram)                # funciona, però és un bucle Python
b = pd.cut(comandes["import_comanda"], [0, 50, 150, np.inf], labels=["baix", "mitja", "alt"], right=False)   # vectoritzat
# tots dos: {'mitja': 1596, 'alt': 898, 'baix': 506}; amb 3.000 files no hi ha diferència; amb 3 milions sí
comandes["categoria"].map({"electronica": "ELEC", "llar": "LLAR", "informatica": "INFO", "accessoris": "ACC"})
comandes["categoria"].str.upper(); comandes["categoria"].str.len(); comandes["categoria"].str.contains("elec")

  1. Matplotlib: figura, eixos i els quatre gràfics bàsics

Matplotlib té dues capes: la ràpida (plt.plot(...)) i l'orientada a objectes, que és la que convé aprendre: una figura (fig, el llenç) conté un o més eixos (ax, cada gràfic), i sobre els eixos es dibuixa i s'etiqueta. Com que l'entorn del curs no mostra imatges, desem a fitxer i descrivim el que s'hi veu.

import matplotlib.pyplot as plt

# 1) Histograma d'imports
fig, ax = plt.subplots(figsize=(7, 4))                       # una figura amb un eix
ax.hist(comandes["import_comanda"], bins=40, color="steelblue", edgecolor="white")
ax.axvline(comandes["import_comanda"].median(), color="darkred", linestyle="--",
           label=f"mediana {comandes['import_comanda'].median():.0f} EUR")   # línia vertical
ax.set_xlabel("Import de la comanda (EUR)"); ax.set_ylabel("Nombre de comandes")
ax.set_title("NovaMarket: distribució d'imports (3.000 comandes)")
ax.legend()
fig.tight_layout(); fig.savefig("hist_imports.png", dpi=120)   # també .pdf, .svg

# 2) Barres: taxa de devolució per categoria
taxa = comandes.groupby("categoria")["retornat"].mean().sort_values(ascending=False)
fig, ax = plt.subplots(figsize=(6, 4))
barres = ax.bar(taxa.index, taxa.values * 100, color=["firebrick", "darkorange", "goldenrod", "seagreen"])
ax.bar_label(barres, fmt="%.1f %%")                          # el valor a sobre de cada barra
ax.axhline(comandes["retornat"].mean() * 100, color="gray", linestyle=":", label="mitjana global")
ax.set_ylabel("Taxa de devolució (%)"); ax.set_title("Taxa de devolució per categoria"); ax.legend()
fig.tight_layout(); fig.savefig("barres_taxa.png", dpi=120)

# 3) Línia: demanda setmanal i mitjana mòbil
demanda["mitjana_mobil_8"] = demanda["unitats"].rolling(8, center=True).mean()
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(demanda["data"], demanda["unitats"], color="lightgray", marker=".", linewidth=1, label="unitats setmanals")
ax.plot(demanda["data"], demanda["mitjana_mobil_8"], color="navy", linewidth=2, label="mitjana mòbil (8 setmanes)")
pic = demanda.loc[demanda["unitats"].idxmax()]
ax.annotate("Black Friday", xy=(pic["data"], pic["unitats"]),
            xytext=(pic["data"], pic["unitats"] + 60), arrowprops=dict(arrowstyle="->"), ha="center")
ax.set_xlabel("Setmana"); ax.set_ylabel("Unitats venudes")
ax.set_title("Demanda setmanal del robot aspirador NovaClean"); ax.legend(loc="upper left"); ax.grid(alpha=0.3)
fig.tight_layout(); fig.savefig("demanda_mitjana_mobil.png", dpi=120)

# 4) Subgràfics: dispersió i línia, l'un al costat de l'altre
fig, eixos = plt.subplots(1, 2, figsize=(10, 4))             # 1 fila x 2 columnes -> array d'eixos
eixos[0].scatter(comandes["import_comanda"], comandes["dies_lliurament"], c=comandes["retornat"], cmap="coolwarm", s=8, alpha=0.6)
eixos[0].set_xlabel("Import (EUR)"); eixos[0].set_ylabel("Dies de lliurament"); eixos[0].set_title("Comandes (vermell = retornada)")
per_dia = comandes.groupby("dies_lliurament")["retornat"].mean() * 100
eixos[1].plot(per_dia.index, per_dia.values, marker="o", color="darkred")
eixos[1].set_xlabel("Dies de lliurament"); eixos[1].set_ylabel("Taxa de devolució (%)"); eixos[1].set_title("Taxa segons dies de lliurament")
fig.suptitle("Import, lliurament i devolucions"); fig.tight_layout(); fig.savefig("subplots.png", dpi=120)

Què es veu a cada fitxer:

  • hist_imports.png: una campana asimètrica amb el pic a la barra de 69-84 € (277 comandes), la línia discontínua de la mediana a 108 € a la dreta del pic i una cua que s'allarga fins a 630 € amb poques desenes de comandes per sobre de 400. És la distribució gamma del generador i la raó per la qual a 04-03 vam parlar d'escalar i d'atípics.
  • barres_taxa.png: quatre barres descendents, electrònica 20,7 %, informàtica 15,9 %, llar 13,5 %, accessoris 13,0 %, amb la línia puntejada de la mitjana global (16,4 %) creuant entre les dues primeres i les dues últimes.
  • demanda_mitjana_mobil.png: punts grisos que oscil·len cada setmana entre 308 i 825 unitats, i a sobre una línia blava suau que puja d'unes 350 unitats a principis de 2024 fins a unes 650 a mitjans de 2025 amb dues "geps" anuals (màxim cap al juliol, mínim cap al gener) i dos pics aïllats en gris que la mitjana mòbil gairebé ignora: la setmana del Black Friday de 2024 (724 unitats) i la de 2025 (825), aquesta última amb la fletxa "Black Friday". Tendència + estacionalitat + esdeveniment, exactament el que vam dir que hi havia a 04-02.
  • subplots.png: a l'esquerra, un núvol de punts amb set franges horitzontals (dies de lliurament 1-7), gairebé tot blau (no retornada) i amb punts vermells concentrats cap a la dreta (imports alts) i cap amunt (lliuraments de 5-7 dies); a la dreta, una línia que puja gairebé en recta del 8,6 % de devolucions amb lliurament en 1 dia al 26 % amb 7 dies. És la relació que el +0.35 * (dies_lliurament - 4) del generador amaga.

Desa les figures amb fig.savefig; en un notebook (07-04) es mostren soles. Quan el gràfic sigui estadístic i vulguis que surti bé a la primera, seaborn (a sobre de Matplotlib: sns.histplot, sns.barplot, sns.heatmap per a matrius de correlació i de confusió) estalvia codi; per a gràfics interactius en web, plotly. Totes dues fan servir DataFrames directament. El mateix pandas té df.plot() com a drecera sobre Matplotlib.

  1. Taula "operació → com es fa"

Operació (el que vam fer al mòdul 4) NumPy pandas
Crear dades reproduïbles rng = np.random.default_rng(42); rng.normal, rng.integers, rng.choice pd.DataFrame({...}) amb els arrays
Filtrar files per condició a[a > 100], a[(c1) & (c2)] df[df["import_comanda"] > 100], df.query("...")
Condicional vectoritzat np.where(cond, x, y) np.where sobre columnes, pd.cut per a trams
Comptar / proporció que compleix (a > 100).sum() / .mean() (df["x"] > 100).sum(), df["retornat"].mean()
Mitjana/desv per columna a.mean(axis=0), a.std(axis=0) df.mean(numeric_only=True), df.describe()
Estandarditzar (a - a.mean(0)) / a.std(0) (broadcasting) igual, o StandardScaler (07-03)
Taxa per grup (a mà amb màscares per valor) df.groupby("cat")["retornat"].mean()
Diverses agregacions amb nom df.groupby("k").agg(n=("x","size"), m=("y","mean"))
Unir dues taules per clau df1.merge(df2, on="clau", how="left")
Noves columnes derivades a[:,0] / a[:,1] df["c"] = df["a"] / df["b"]
Valors absents np.isnan(a), np.nanmean(a) df.isna().sum(), df.dropna(), df.fillna({...})
Dates np.datetime64 (bàsic) pd.to_datetime, .dt.month, resample("MS").sum(), rolling(4).mean()
Producte matricial / capa lineal X @ w + b df.values @ w
Llegir/escriure np.loadtxt, np.save pd.read_csv, df.to_csv(index=False), read_sql, read_parquet
Convertir a scikit-learn / PyTorch ja és un array df.values / df.to_numpy(); torch.tensor(df.values, dtype=torch.float32)

Errors Comuns i Consells

  • Bucles for sobre files d'un DataFrame (for i in range(len(df)), iterrows). És l'error número u de qui arriba d'altres llenguatges; gairebé sempre existeix una operació vectoritzada, un groupby o un merge que ho fa en una línia i cent vegades més ràpid.
  • Modificar una vista creient que és una còpia (NumPy) o una còpia creient que és la vista (pandas amb df[mask]["col"] = ...). Regles: .copy() quan vulguis independència; df.loc[mask, "col"] = ... per assignar.
  • Confondre loc i iloc després de filtrar o ordenar: df.loc[0] és la fila amb etiqueta 0, que pot no existir; df.iloc[0] és la primera fila. reset_index(drop=True) després d'un filtre et retorna etiquetes 0..n-1 si les necessites.
  • Formes incompatibles: llegeix el missatge could not be broadcast together with shapes (a,) (b,) i aplica la regla de dreta a esquerra; reshape(-1, 1) converteix un vector (n,) en columna (n, 1), cosa que scikit-learn exigeix quan hi ha una sola característica.
  • Dates llegides com a text: després de read_csv, df.dtypes ha de dir datetime64; si diu object/str, aplica pd.to_datetime(..., dayfirst=True) o parse_dates a la lectura. I en un CSV amb configuració regional europea revisa sep=";" i decimal=",".
  • NaN silenciosos: mean() els ignora i pots no assabentar-te que falten dades. info() i isna().sum() sempre al principi.
  • Gràfics sense etiquetes ni unitats: un ax.set_xlabel costa un segon i evita que el Diego pregunti "això són euros o unitats?". I desa sempre amb tight_layout() perquè no es tallin els textos.

Exercicis

Exercici 1. Amb NumPy pur (sense pandas), a partir de comandes["import_comanda"].to_numpy() i comandes["retornat"].to_numpy(): (a) calcula la taxa de devolució de les comandes per sobre i per sota de la mediana d'import fent servir màscares; (b) estandarditza l'import amb broadcasting i comprova que la mitjana resultant és ≈ 0 i la desviació ≈ 1; (c) construeix amb np.where una etiqueta "car"/"barat" i compta quants n'hi ha de cadascuna amb una màscara.

Exercici 2. Amb pandas: calcula l'import mitjà i la taxa de devolució per categoria i per client nou/recurrent en una sola taula amb groupby i agregació amb nom, i porta-la a format ample amb unstack perquè les columnes siguin nou/recurrent. A quina categoria és més gran la diferència de taxa entre nous i recurrents? Després, uneix amb la taula productes de la secció 9 i calcula el cost total de devolucions per magatzem i tipus de client.

Exercici 3. Amb la demanda setmanal: (a) remostreja a trimestres ("QS") sumant unitats i identifica el trimestre amb més vendes; (b) calcula la mitjana mòbil de 12 setmanes i la variació percentual setmana a setmana de la sèrie suavitzada (pct_change), i localitza la setmana amb més pujada relativa; (c) dibuixa (desant a fitxer) les unitats per any com dues línies superposades (dia de l'any a l'eix x, una línia per any; pista: .dt.year i .dt.dayofyear) i descriu què s'hi veu.

Solucions

Solució 1.

import_comanda = comandes["import_comanda"].to_numpy(); retornat = comandes["retornat"].to_numpy()
mediana = np.median(import_comanda)
print(retornat[import_comanda > mediana].mean().round(3), retornat[import_comanda <= mediana].mean().round(3))
# 0.254 0.075   -> les comandes cares es retornen més de 3 vegades més
z = (import_comanda - import_comanda.mean()) / import_comanda.std()
print(z.mean().round(10), z.std().round(6))      # 0.0  1.0   (arrodoniment: la mitjana és de l'ordre d'1e-16)
etiqueta = np.where(import_comanda > mediana, "car", "barat")
print((etiqueta == "car").sum(), (etiqueta == "barat").sum())   # 1500 1500

Amb 3.000 valors i mediana a la posició central, la meitat exacta queda a cada costat. Fixa't que ni tan sols hem necessitat pandas: és el que scikit-learn fa per sota amb les columnes numèriques.

Solució 2.

t = comandes.groupby(["categoria", "client_nou"]).agg(import_mitja=("import_comanda", "mean"),
                                                      taxa=("retornat", "mean")).round(3)
ample = t["taxa"].unstack().rename(columns={0: "recurrent", 1: "nou"})
ample["diferencia"] = ample["nou"] - ample["recurrent"]
print(ample.sort_values("diferencia", ascending=False))
#              recurrent    nou  diferencia
# electronica      0.116  0.415       0.299
# llar             0.069  0.297       0.228
# informatica      0.099  0.304       0.205
# accessoris       0.068  0.259       0.191
unit = comandes.merge(productes, on="categoria", how="left")
unit["cost_dev"] = unit["retornat"] * unit["cost_devolucio"]
print(unit.groupby(["magatzem", "client_nou"])["cost_dev"].sum().unstack())
# client_nou       0       1
# magatzem
# Getafe      2083.0  2947.0
# Zaragoza     872.0  1570.0

La diferència més gran és a electrònica (30 punts): el generador combina l'efecte de categoria amb el de client nou i el terme creuat nou × import, i l'electrònica té imports alts. En cost, els clients nous (el 30 % de les comandes) generen el 60 % del cost de devolucions als dos magatzems (2.947 € de 5.030 a Getafe; 1.570 € de 2.442 a Zaragoza): un argument quantificat perquè el Diego prioritzi el cas d'ús 3 en clients nous.

Solució 3.

serie = demanda.set_index("data")["unitats"]
trim = serie.resample("QS").sum()
print(trim.idxmax().date(), trim.max())          # 2025-07-01 8404  (tercer trimestre de 2025)
suau = serie.rolling(12).mean()
var = suau.pct_change() * 100
print(var.idxmax().date(), var.max().round(2))   # 2024-04-22 3.4 %  (setmana en què entra la pujada de primavera)
demanda["any_"] = demanda["data"].dt.year; demanda["dia_any"] = demanda["data"].dt.dayofyear
fig, ax = plt.subplots(figsize=(9, 4))
for any_, g in demanda.groupby("any_"):                  # un grup (i una línia) per any
    ax.plot(g["dia_any"], g["unitats"], marker=".", label=str(any_))
ax.set_xlabel("Dia de l'any"); ax.set_ylabel("Unitats"); ax.set_title("NovaClean: demanda setmanal, un any per línia")
ax.legend(); fig.tight_layout(); fig.savefig("demanda_per_any.png", dpi=120)

Es veuen dues línies amb la mateixa forma (pugen fins a l'estiu, baixen fins a l'hivern, amb un pic aïllat a finals de novembre, el Black Friday), la de 2025 desplaçada unes 130 unitats per sobre de la de 2024 en tota la seva longitud (mitjanes anuals: 471 i 597 unitats): la tendència (+2,5 unitats per setmana × 52 setmanes) i l'estacionalitat separades a simple vista. Aquest és el gràfic que la Marta ensenyaria al Diego abans de parlar de models de previsió (cas d'ús 2).

Conclusió

Hem obert la caixa d'eines bàsica del Python científic amb les dades de NovaMarket. NumPy aporta l'ndarray (bloc contigu, shape i dtype), la creació i indexació amb vistes, les operacions vectoritzades i el broadcasting (amb el qual estandarditzem columnes en una línia), les agregacions per eix, les màscares booleanes per filtrar i comptar, el generador default_rng que fa reproduïbles les nostres 3.000 comandes i el producte @ amb què s'escriu qualsevol capa lineal; i és la base de tota la resta, inclosos els tensors de PyTorch, que són arrays amb gradient. pandas hi afegeix etiquetes: Series i DataFrame, read_csv/to_csv, la inspecció obligatòria amb head/info/describe, la selecció amb loc/iloc i màscares, el tractament de NaN, el groupby que respon a les preguntes del Diego (electrònica 20,7 % de devolucions; clients nous 3,7 vegades més), el merge amb la taula de productes que converteix devolucions en euros per magatzem, i les dates amb .dt, resample (de setmanal a mensual) i rolling. Matplotlib dibuixa amb figura i eixos: histograma d'imports, barres de taxa, la línia de la demanda amb la seva mitjana mòbil i el Black Friday assenyalat, i subgràfics. La taula de la secció 13 resumeix com es fa cada operació que al mòdul 4 vam donar per sabuda.

Amb arrays i taules dominats, el següent pas del taller de la Marta és el mapa de les llibreries que es construeixen a sobre: scikit-learn i la seva API fit/predict, PyTorch davant de TensorFlow/Keras, Hugging Face per al llenguatge, OpenCV per a la visió, els SDK de LLM, experta i pgmpy per a les regles i la probabilitat, OR-Tools per a l'optimització, i les eines per desar, servir i fer el seguiment de models. És 07-03, Eines i Llibreries Populars, on a més desarem i recarregarem el pipeline de 04-03 i l'MLP de 05-02 i comprovarem que continuen predint el mateix.

Fonaments d'Intel·ligència Artificial (IA)

Mòdul 1: Introducció a la Intel·ligència Artificial

Mòdul 2: Principis Bàsics de la IA

Mòdul 3: Algorismes en IA

Mòdul 4: Aprenentatge Automàtic (Machine Learning)

Mòdul 5: Xarxes Neuronals i Deep Learning

Mòdul 6: Lògica i Sistemes Experts

Mòdul 7: Eines i Llenguatges de Programació en IA

Mòdul 8: Projectes i Casos d'Estudi

Mòdul 9: Exercicis i Pràctiques

Mòdul 10: Recursos Addicionals

© Copyright 2026. Tots els drets reservats