A 05-02 vam classificar: retard sí o no. Però el cap d'operacions de Rutalia vol més: "no em diguis si arribarà tard, digue'm quants minuts trigarà". Predir un valor continu és regressió, l'altra meitat de l'aprenentatge supervisat. En aquesta lliçó la treballem a fons amb el dataset canònic: la regressió lineal amb la seva solució analítica exacta (àlgebra pura amb numpy), i després el protagonista absolut, el descens de gradient — l'algorisme iteratiu d'optimització que va quedar pendent a 05-02 i que és, literalment, el motor de les xarxes neuronals de 05-04. Completarem el quadre amb la regularització Ridge/Lasso, la regressió polinòmica (on el trade-off biaix-variància de 05-01 es formalitza) i les mètriques pròpies de la regressió.

Contingut

  1. El problema: predir minuts_lliurament
  2. Regressió lineal simple: la recta de mínims quadrats
  3. Regressió lineal múltiple: solució analítica amb numpy
  4. Descens de gradient: L'algorisme
  5. Batch, estocàstic i mini-batch
  6. Regularització: Ridge i Lasso
  7. Regressió polinòmica i el trade-off biaix-variància
  8. Mètriques de regressió: MSE, RMSE, MAE, R²

El problema: predir minuts_lliurament

Mateix dataset canònic de 05-01 (llavor 42), nou objectiu: la columna contínua minuts_lliurament.

dades = generar_dataset()                       # 05-01
X_num = np.column_stack([dades["distancia_km"], dades["pes_kg"],
                         dades["hora_sortida"], dades["dia_setmana"]])
X = np.column_stack([X_num, one_hot(dades["zona"], ZONES)])
y = dades["minuts_lliurament"]                  # ← continu, ja no binari
X_tr, X_te, y_tr, y_te = train_test_split_manual(X, y)
X_tr_e, X_te_e = estandarditzar(X_tr, X_te)

Recorda que la veritat oculta del generador és multiplicativa (base × congestió × hora punta ×...): un model lineal no la podrà capturar del tot. Perfecte: veurem amb precisió quant se li escapa i per què.

Regressió lineal simple: la recta de mínims quadrats

Comencem amb una sola feature, la més informativa: distancia_km. Busquem la recta

minuts ≈ w · distancia + b

Què significa "la millor recta"? La intuïció geomètrica: dibuixa els 2000 lliuraments com a punts (distància, minuts). Cada recta candidata deixa un residu a cada punt: la distància vertical entre el minut real i el predit. Mínims quadrats tria la recta que minimitza la suma dels residus al quadrat. Per què al quadrat i no el valor absolut? Tres raons: penalitza més els errors grans, fa la funció derivable a tot arreu (clau per al que ve) i té solució tancada:

w = cov(x, y) / var(x)        b = ȳ − w·x̄
x = dades["distancia_km"]
w = np.cov(x, y, bias=True)[0, 1] / x.var()   # pendent
b = y.mean() - w * x.mean()                    # ordenada a l'origen
print(f"minuts ≈ {w:.2f}·km + {b:.2f}")

Obtindràs una cosa com minuts ≈ 6.9·km + 6.5: el model ha redescobert aproximadament els "6 min/km + 5 fixos" del generador — sense que ningú no li ho digués. El pendent no és exactament 6,0 perquè els factors multiplicatius (congestió, hora punta) inflen la relació mitjana. Això és aprendre de les dades en la seva forma més pura.

Supòsits que convé conèixer (el model lineal els assumeix): relació realment lineal, residus independents, de variància constant i sense outliers dominants. Quan fallen, la recta es continua calculant... però deixa de significar el que creus.

Regressió lineal múltiple: solució analítica amb numpy

Amb les 13 features, busquem el vector w (13 pesos) i el biaix b:

ŷ = X·w + b

El truc estàndard és absorbir b afegint una columna d'uns a X (així b és un pes més). Minimitzar la suma de quadrats ‖X·w − y‖² té solució analítica, les equacions normals:

w* = (XᵀX)⁻¹ Xᵀ y

La intuïció geomètrica en una frase: X·w només pot produir vectors al subespai generat per les columnes de X; el millor ŷ és la projecció ortogonal de y sobre aquest subespai, i les equacions normals diuen exactament això (el residu ha de ser perpendicular a totes les columnes: Xᵀ(y − Xw) = 0).

def regressio_lineal_analitica(X, y):
    """Resol les equacions normals. Retorna els pesos (inclou el biaix)."""
    Xb = np.column_stack([np.ones(len(X)), X])     # columna d'uns → biaix
    # lstsq és més estable numèricament que invertir XᵀX explícitament
    w, *_ = np.linalg.lstsq(Xb, y, rcond=None)
    return w

def predir_lineal(w, X):
    return np.column_stack([np.ones(len(X)), X]) @ w

w = regressio_lineal_analitica(X_tr_e, y_tr)
pred = predir_lineal(w, X_te_e)
print(f"RMSE test: {np.sqrt(np.mean((pred - y_te) ** 2)):.2f} minuts")

Cost: O(n·d²) per formar XᵀX més O(d³) per resoldre. Amb d=13 és instantani. Aleshores, per què complicar-se amb mètodes iteratius? Perquè la solució tancada no escala ni generalitza: amb d = 10⁵ features l'O(d³) explota, i —més important— només existeix per a mínims quadrats lineals. Per a la regressió logística de 05-02 no hi ha fórmula tancada. Per a les xarxes de 05-04, tampoc. Necessitem un algorisme general de minimització.

Descens de gradient: L'algorisme

Aquest apartat és el cor de la lliçó — i el pont cap a tot el que queda de mòdul.

La funció de cost

Definim l'error quadràtic mitjà (MSE) com a funció dels paràmetres:

J(w, b) = (1/n) · Σᵢ (ŷᵢ − yᵢ)²     amb  ŷᵢ = w·xᵢ + b

Canvia el punt de vista: les dades estan fixes; les variables són els paràmetres. J és una superfície sobre l'espai de paràmetres — per a mínims quadrats, un paraboloide convex amb un únic mínim (el que la fórmula analítica troba de cop).

La idea: baixar la muntanya a cegues

Imagina ser en aquesta superfície amb boira: només perceps el pendent sota els teus peus. Estratègia: fes un pas petit costa avall i repeteix. El gradient ∇J (vector de derivades parcials) apunta en la direcció de màxima pujada; caminar en −∇J és la baixada més ràpida localment.

repetir fins a convergir:
    w ← w − α · ∂J/∂w
    b ← b − α · ∂J/∂b

α és el learning rate (taxa d'aprenentatge): la mida del pas.

Les derivades

Derivant J (regla de la cadena sobre el quadrat; el 2 s'absorbeix en la convenció):

∂J/∂w = (2/n) · Xᵀ(ŷ − y)          ∂J/∂b = (2/n) · Σ(ŷ − y)

Lectura intuïtiva: el gradient respecte de cada pes és l'error mitjà ponderat per la feature corresponent. Si el model es queda curt en els lliuraments llargs, la component de distancia_km del gradient empeny el seu pes cap amunt. L'error, literalment, dirigeix l'aprenentatge.

Implementació amb traça del descens

def descens_gradient(X, y, alpha=0.1, epoques=200):
    """Minimitza el MSE. Retorna pesos, biaix i historial de cost."""
    n, d = X.shape
    w, b = np.zeros(d), 0.0                    # arrenquem a l'origen
    historial = []
    for epoca in range(epoques):
        y_hat = X @ w + b                      # 1. predicció actual
        error = y_hat - y                      # 2. residus
        grad_w = (2 / n) * (X.T @ error)       # 3. gradient respecte de w
        grad_b = (2 / n) * error.sum()         #    ...i respecte de b
        w -= alpha * grad_w                    # 4. pas costa avall
        b -= alpha * grad_b
        historial.append(np.mean(error ** 2))  # cost d'aquesta època
    return w, b, historial

w_gd, b_gd, hist = descens_gradient(X_tr_e, y_tr, alpha=0.1, epoques=200)
for e in (0, 9, 49, 199):
    print(f"època {e:3d}: MSE = {hist[e]:8.2f}")

Veuràs el MSE desplomar-se a les primeres èpoques i estabilitzar-se: això és la convergència. Compara w_gd amb la solució analítica: han de coincidir fins al tercer decimal — dos algorismes radicalment diferents, el mateix mínim, perquè el paraboloide només en té un.

El learning rate: la peça delicada

α Comportament Símptoma a l'historial de cost
Massa petit (0,0001) Convergeix, però després de moltíssimes èpoques Baixa amb una lentitud exasperant
Adequat (≈0,01-0,3 aquí) Convergència ràpida i estable Caiguda suau fins a un altiplà
Massa gran (1,5) Cada pas salta a l'altra banda de la vall, cada cop més lluny El cost creix i divergeix cap a infinit/NaN

Prova alpha=1.5 i observa l'explosió: és l'error més comú a la pràctica. L'historial de cost és el teu electrocardiograma: mira-te'l sempre. Fixa't també per què estandarditzem: amb features en escales dispars, la superfície J és una vall allargada i estreta, i el descens zigzagueja; amb features estandarditzades la vall és rodona i el mateix α serveix per a totes les direccions.

Per què aquest algorisme és L'algorisme

El descens de gradient només exigeix que el cost sigui derivable. Canvia el MSE pel cost de la regressió logística: mateixes quatre línies. Encadena milions de paràmetres en una xarxa neuronal: el mateix bucle, amb el gradient calculat per backpropagation (05-04). Tot el deep learning modern és, en essència, aquest for amb gradients calculats amb astúcia. Per això l'hem escrit a mà.

Batch, estocàstic i mini-batch

La nostra versió fa servir els n lliuraments a cada pas (batch). Amb els milions de registres reals de Rutalia, cada pas costaria massa. Alternatives:

Variant Dades per pas Cost per pas Trajectòria Ús típic
Batch Totes (n) O(n·d) Suau, determinista Datasets petits
Estocàstic (SGD) 1 exemple O(d) Molt sorollosa, oscil·la al voltant del mínim Streaming, online
Mini-batch 32-256 exemples O(B·d) Soroll moderat L'estàndard (i el de 05-04)

El soroll de l'SGD no és només un defecte: en superfícies no convexes (les de les xarxes neuronals) ajuda a escapar de mínims locals pobres. El mini-batch equilibra: gradient raonablement fiable, cost per pas acotat i operacions matricials que aprofiten la vectorització. Guarda't aquest concepte: reapareix tal qual a l'entrenament de la xarxa de 05-04.

def descens_minibatch(X, y, alpha=0.05, epoques=50, batch=64, llavor=42):
    rng = np.random.default_rng(llavor)
    n, d = X.shape
    w, b = np.zeros(d), 0.0
    for _ in range(epoques):
        ordre = rng.permutation(n)                 # barrejar cada època
        for i in range(0, n, batch):
            lot = ordre[i:i + batch]
            err = X[lot] @ w + b - y[lot]
            w -= alpha * (2 / len(lot)) * (X[lot].T @ err)
            b -= alpha * (2 / len(lot)) * err.sum()
    return w, b

Regularització: Ridge i Lasso

Amb 13 features anem sobrats de dades. Però imagina el cas real: centenars de features (una per creuament de zona×hora×dia, meteorologia, tipus de client...). Amb moltes features i dades limitades, el model lineal troba pesos enormes que exploten correlacions espúries de l'entrenament: sobreajustament, un altre cop.

La regularització afegeix al cost una penalització per pesos grans:

Mètode Cost Efecte sobre els pesos
Ridge (L2) MSE + λ·Σwⱼ² Els encongeix tots suaument cap a 0; mai no arriben a 0 exacte
Lasso (L1) MSE + λ·Σ|wⱼ| Porta els pesos irrellevants a exactament 0: selecciona features

λ controla la força: λ=0 és la regressió normal; un λ enorme ho aixafa tot cap al model trivial. Es tria amb validació creuada (05-01), mai amb el test.

Per què L1 produeix zeros exactes i L2 no? La penalització L2 és un paraboloide (el seu gradient s'anul·la suaument a 0: empeny proporcionalment al pes, cada cop menys), mentre que L1 té "cantonades": empeny cap a 0 amb força constant λ, guanyi el que guanyi el pes, i els pesos que aporten menys que λ acaben clavats a zero. Per això Lasso serveix com a selector automàtic de features: a Rutalia et diria, amb centenars de columnes candidates, quines importen de debò.

En Ridge, incorporar-ho al descens de gradient costa una línia (grad_w += 2 * lam * w), i fins i tot conserva solució analítica: w = (XᵀX + λI)⁻¹Xᵀy. A sklearn: Ridge(alpha=...), Lasso(alpha=...) (compte: sklearn anomena alpha la λ, no el learning rate).

Regressió polinòmica i el trade-off biaix-variància

La veritat de Rutalia no és lineal (els factors es multipliquen). Truc elegant: mantenir el model lineal en els pesos però enriquir les features amb termes derivats: distancia², distancia·hora_punta, etc. La maquinària (analítica o gradient) no canvia en absolut: només creix X.

from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline

for grau in (1, 2, 5):
    model = make_pipeline(PolynomialFeatures(grau), LinearRegression())
    model.fit(X_tr_e, y_tr)
    mse_tr = np.mean((model.predict(X_tr_e) - y_tr) ** 2)
    mse_te = np.mean((model.predict(X_te_e) - y_te) ** 2)
    print(f"grau {grau}: MSE train={mse_tr:7.2f}  test={mse_te:7.2f}")

El grau 1 es queda curt; el grau 2 captura les interaccions multiplicatives del generador i millora el test; el grau 5 clava el train i empitjora el test. És la corba en U de 05-01, ara amb nom tècnic. L'error de generalització es descompon en:

  • Biaix: error per rigidesa del model — la recta no pot representar la veritat multiplicativa (grau 1: biaix alt).
  • Variància: error per sensibilitat a la mostra concreta — el polinomi de grau 5 canviaria molt si regeneréssim les dades amb una altra llavor (variància alta).
  • Soroll irreductible: el rng.normal(0, 4) del generador. Cap model no baixarà d'un MSE ≈ 16 (= 4²) en test; si algun ho fa en train, està memoritzant soroll.

Capacitat ↑ ⇒ biaix ↓ i variància ↑: l'òptim és al colze. Tot el que hem vist per combatre el sobreajustament (la k de k-NN, la poda d'arbres, el bagging, la λ de Ridge/Lasso, el grau del polinomi) són botons del mateix regulador.

Menció breu: els arbres de regressió són els arbres de 05-02 amb fulles que prediuen la mitjana dels seus exemples i talls que minimitzen la variància en lloc de Gini; el random forest de regressió fa la mitjana de les seves sortides. Capturen no-linealitats sense enginyeria de features i són l'alternativa forta a l'enfocament polinòmic.

Mètriques de regressió: MSE, RMSE, MAE, R²

Mètrica Fórmula Unitats Lectura
MSE mitjana de (ŷ−y)² minuts² La que optimitzem; penalitza molt els errors grans
RMSE √MSE minuts La mateixa informació, en unitats interpretables
MAE mitjana de |ŷ−y| minuts Error "típic"; robusta davant d'outliers
1 − MSE/var(y) adimensional Fracció de la variància explicada; 1=perfecte, 0=com predir la mitjana, <0=pitjor que la mitjana
def metriques_regressio(y_real, y_pred):
    mse = np.mean((y_pred - y_real) ** 2)
    return {"MSE": mse, "RMSE": np.sqrt(mse),
            "MAE": np.mean(np.abs(y_pred - y_real)),
            "R2": 1 - mse / np.var(y_real)}

Per parlar amb negoci, fes servir RMSE o MAE ("ens equivoquem uns 5 minuts per lliurament"); per comparar models entre si, R². Si RMSE ≫ MAE, hi ha outliers: uns pocs lliuraments amb errors enormes que el quadrat amplifica — investiga'ls abans de canviar de model.

Errors Comuns i Consells

  • Learning rate a cegues. Si el cost divergeix, α és massa gran; si baixa a pas de tortuga, massa petit. Grafica l'historial de cost a cada experiment: és la teva eina de diagnòstic número u.
  • Descens de gradient sense estandarditzar. Amb escales dispars, la vall de J és estreta i allargada i un α únic no serveix per a totes les direccions: convergència lentíssima o divergència. Estandarditza sempre abans del gradient.
  • Regularitzar el biaix b. La penalització s'ha d'aplicar només als pesos w; penalitzar b desplaça artificialment les prediccions. (sklearn ja ho fa bé; a la teva implementació manual, vigila aquest detall.)
  • Triar λ o el grau del polinomi mirant el test. És la fuga de 05-01 en la seva disfressa més freqüent. Hiperparàmetres → validació creuada; test → una sola vegada, al final.
  • Reportar MSE a negoci. "MSE de 26 minuts quadrats" no significa res per a operacions. Tradueix a RMSE/MAE en minuts.
  • Consell: tingues sempre dues referències: el model trivial (predir la mitjana, R²=0) com a sostre de la mediocritat i el soroll irreductible com a terra de l'error (aquí RMSE ≈ 4): entre tots dos viu qualsevol model honest.

Exercicis

  1. L'electrocardiograma del descens. Executa descens_gradient sobre el dataset canònic estandarditzat amb alpha ∈ {0.001, 0.1, 1.1} i 100 èpoques. Imprimeix el MSE a les èpoques 0, 10, 50 i 99 per a cada α. Classifica cada comportament (lent / correcte / divergent) i comprova que amb α=0,1 els pesos coincideixen amb la solució analítica.

  2. Ridge contra el sobreajustament polinòmic. Genera features polinòmiques de grau 5 i compara LinearRegression() amb Ridge(alpha=10) sobre train i test (MSE). Quant redueix Ridge el forat train-test? Prova també Lasso(alpha=0.5) i compta quants coeficients deixa exactament a 0 (np.sum(model.coef_ == 0)).

  3. Quant marge queda? Calcula RMSE i R² en test per a: (a) el model trivial que prediu la mitjana de train, (b) la teva regressió lineal, (c) un RandomForestRegressor(n_estimators=200). Sabent que el soroll del generador és σ=4, quin RMSE és inassolible i quant li queda per esprémer a cada model?

Solucions

Exercici 1:

for a in (0.001, 0.1, 1.1):
    w_, b_, h = descens_gradient(X_tr_e, y_tr, alpha=a, epoques=100)
    print(f"α={a}: " + "  ".join(f"e{e}={h[e]:.1f}" for e in (0, 10, 50, 99)))

w_analitica = regressio_lineal_analitica(X_tr_e, y_tr)
w_gd, b_gd, _ = descens_gradient(X_tr_e, y_tr, alpha=0.1, epoques=2000)
print(np.allclose(w_analitica[1:], w_gd, atol=1e-2))   # True

α=0,001: el MSE baixa però a l'època 99 encara és lluny del mínim (lent). α=0,1: caiguda ràpida i altiplà (correcte). α=1,1: el MSE creix època rere època fins a l'overflow (divergent: cada pas salta més enllà de l'altra banda de la vall). Amb prou èpoques, α=0,1 reprodueix la solució analítica: dos camins, un sol mínim, perquè el MSE lineal és convex.

Exercici 2:

from sklearn.linear_model import Ridge, Lasso

pf = PolynomialFeatures(5)
Xp_tr, Xp_te = pf.fit_transform(X_tr_e), pf.transform(X_te_e)

for nom, m in [("lineal", LinearRegression()),
               ("ridge", Ridge(alpha=10)), ("lasso", Lasso(alpha=0.5))]:
    m.fit(Xp_tr, y_tr)
    tr = np.mean((m.predict(Xp_tr) - y_tr) ** 2)
    te = np.mean((m.predict(Xp_te) - y_te) ** 2)
    print(f"{nom}: train={tr:.1f} test={te:.1f}")
print("coefs a zero al lasso:", np.sum(Lasso(alpha=0.5).fit(Xp_tr, y_tr).coef_ == 0))

La lineal de grau 5 mostra un forat train-test notable (sobreajustament); Ridge encongeix els pesos i tanca bona part del forat cedint una mica de train. Lasso, a més, anul·la la gran majoria dels centenars de coeficients polinòmics: els termes supervivents assenyalen les interaccions que importen de debò (distància×hora, distància×zona congestionada).

Exercici 3:

from sklearn.ensemble import RandomForestRegressor

candidats = {
    "trivial": np.full(len(y_te), y_tr.mean()),
    "lineal": predir_lineal(regressio_lineal_analitica(X_tr_e, y_tr), X_te_e),
    "bosc": RandomForestRegressor(n_estimators=200, random_state=42)
            .fit(X_tr, y_tr).predict(X_te),
}
for nom, pred in candidats.items():
    m = metriques_regressio(y_te, pred)
    print(f"{nom}: RMSE={m['RMSE']:.2f}  R2={m['R2']:.3f}")

El trivial marca el terra (R²≈0, RMSE = desviació típica de y). Cap model no pot baixar d'un RMSE ≈ 4 (el σ del soroll del generador: és informació que no existeix a les features). La lineal es queda a mig camí (biaix: no representa la veritat multiplicativa); el bosc, que sí que captura no-linealitats, s'acosta molt més al terra de 4. La distància de cada RMSE a 4 és el marge de millora real de cada model.

Conclusió

Aquesta lliçó deixa tres capes ben assentades. La regressió lineal amb la seva doble solució: l'analítica (projecció ortogonal, equacions normals, lstsq) i la iterativa. El descens de gradient desenvolupat peça a peça —cost MSE, derivades, learning rate, historial de convergència, variants batch/estocàstic/mini-batch— i verificat contra la solució exacta. I el control del sobreajustament amb vocabulari formal: biaix contra variància, amb la regularització Ridge/Lasso i el grau polinòmic com a botons, i el soroll irreductible com a terra que cap model honest no perfora. Però fixa't en la limitació que arrosseguem: per capturar la veritat no lineal de Rutalia hem hagut de fabricar les features nosaltres (polinomis, interaccions) — un altre cop coneixement aportat a mà. I si el model pogués aprendre també les transformacions? Això és exactament una xarxa neuronal: capes de regressions (lineals + activacions) apilades, entrenades d'extrem a extrem amb el mateix descens de gradient que acabes d'implementar. El gradient, això sí, caldrà propagar-lo cap enrere a través de les capes: backpropagation. Ens veiem a 05-04.

© Copyright 2026. Tots els drets reservats