Il Nostro Primo Modello di Machine Learning, Introduzione

Parte (1/2)

Potrebbe interessarti:

Il progetto Python, che comprende gli script mostrati in questo post, e quelli relativi alla creazione dei grafici Matplotlib e Plotly, è disponibile assieme al corso Introduzione al Machine Learning.

Dimmi di Più

In questo post svilupperemo un programma Python che implementa un modello di machine learning (ML) chiamato regressione lineare semplice, che useremo come “banco di lavoro” per comprendere quali siano le parti più importanti che permettono al modello di funzionare correttamente.

Nel contesto di apprendimento supervisionato (supervised learning), un modello è una funzione matematica che impara dai dati di esempio forniti durante la fase di addestramento. In pratica, esso osserva coppie di input e output conosciuti, e cerca di capire la relazione che li collega. Una volta imparata questa relazione, il modello può essere usato per prevedere l’output corrispondente a nuovi dati che non ha mai visto prima.

L’obiettivo principale di un modello di machine learning è trovare una funzione che imiti il comportamento reale del processo che ha generato i dati, detta funzione target. Durante la fase di training, il modello usa i dati $\left(\textbf{x}^{(i)}, y^{(i)}\right)$ del dataset di addestramento per imparare la relazione tra gli input $\textbf{x}^{(i)}$ e gli output corrispondenti $y^{(i)}$. Il risultato di questo apprendimento è la funzione di ipotesi, che rappresenta la miglior approssimazione trovata della funzione target. Una volta addestrato, il modello può essere usato per fare previsioni: nei problemi di regressione stima valori numerici continui, mentre nei problemi di classificazione predice la classe a cui appartiene un nuovo esempio.

Una Breve Introduzione alla Regressione Lineare

La Regressione Lineare è un modello lineare, utilizzata principalmente per prevedere risultati continui (numeri reali). Esso stima il valore di una variabile dipendente continua di output, $\hat{y}^{(i)}$, e presuppone una relazione lineare tra le variabili indipendenti in ingresso, $x^{(i)}_1, \cdots, x^{(i)}_n$, chiamate feature, e il valore in uscita, $\hat{y}^{(i)}$, nei termini dei parametri del modello, in questo caso i pesi $w_1, w_2, \cdots, w_n$. Ciò significa che l’output è modellato come una somma ponderata delle feature, dove i pesi sono applicati linearmente alle feature. Matematicamente, questa relazione può essere espressa come:

$$ \begin{align*} \hat{y}^{(i)} & = w_1x^{(i)}_1 + w_2x^{(i)}_2 + \dots + w_nx^{(i)}_n + b\\[6pt] \end{align*} $$

Ogni peso $w_j$ indica quanto e in che direzione la feature $x^{(i)}_j$ influisce sul valore previsto:

  • un peso positivo aumenta $\hat{y}^{(i)}$;
  • un peso negativo la riduce;
  • un peso vicino a zero ha poca o nessuna influenza sulla previsione.

Cosa si intende per Modello Lineare?

Un modello lineare è un algoritmo che descrive la relazione tra variabili attraverso una combinazione lineare dei suoi parametri. In termini pratici, il modello agisce come una funzione parametrica: durante l’addestramento, l’algoritmo stima i valori dei parametri che minimizzano l’errore rispetto ai dati; una volta definiti, questi diventano costanti che permettono di calcolare previsioni su nuovi input.

La caratteristica distintiva di un modello lineare è che esso deve essere lineare nei termini dei parametri $w_1, \cdots, w_n$ e al termine di bias $b$. Ciò significa che i parametri compaiono solo alla prima potenza e non vengono mai moltiplicati tra loro, divisi o trasformati da funzioni non lineari (come logaritmi o esponenziali). Consideriamo, ad esempio, le seguenti equazioni:

$$ \begin{align*} \hat{y_1} & = w_1x + w_2x + b\\[6pt] \hat{y_2} & = w_1\log(x) + w_2x^2 + b\\[6pt] \end{align*} $$

Entrambi i modelli sono considerati lineari. Mentre il primo è lineare sia nei parametri che nelle variabili, il secondo lo è solo nei parametri. È fondamentale notare che la linearità si riferisce esclusivamente ai parametri: le variabili indipendenti ($x$) possono essere trasformate liberamente (elevate al quadrato, logaritmizzate, ecc.) senza che il modello perda la sua natura lineare.

Un modello lineare assume che ogni variabile di input contribuisca in modo proporzionale e indipendente al risultato finale. Ogni peso quantifica direttamente l’impatto (positivo o negativo) di una specifica variabile sull’output. Questa caratteristica rende i modelli lineari particolarmente adatti quando è richiesta un’elevata interpretabilità, come in ambiti quali economia, medicina o finanza. I modelli lineari hanno anche altri vantaggi:

  • richiedono meno dati per ottenere buone prestazioni;
  • sono computazionalmente efficienti e veloci da addestrare;
  • tendono a generalizzare meglio quando la relazione tra i dati è relativamente semplice.

La regressione lineare permette di quantificare di quanto aumenterà o diminuirà il valore di $\hat{y}^{(i)}$ all’aumentare del valore di una variabile indipendente $x^{(i)}_j$. Un esempio di utilizzo di questo modello è la previsione del prezzo di un’immobile (variabile dipendente), in base a determinate caratteristiche o feature dello stesso, quali ad esempio la metratura, il numero di stanze, l’età dell’immobile, la sua posizione, ecc. (variabili indipendenti).

Se abbiamo una sola variabile indipendente $x^{(i)}$, e la variabile dipendente $\hat{y}^{(i)}$, possiamo usare la regressione lineare semplice. In questo caso, la relazione tra $x^{(i)}$ e $\hat{y}^{(i)}$ può essere approssimata sul piano cartesiano $x\hat{y}$ tramite una retta, definita dall’equazione:

$$ \hat{y}^{(i)} = wx^{(i)} + b \tag{1} $$

che possiamo riscrivere nella forma più conosciuta:

$$ \hat{y}^{(i)} = mx^{(i)} + b \tag{2} $$

dove:

  • $m$ è il coefficiente angolare della retta, o pendenza.
  • $b$ è il valore di intercetta, ovvero il valore di $y^{(i)}$ quando $x^{(i)} = 0$.

Ad esempio, nel grafico seguente, la variabile indipendente $x^{(1)} = 6$ è legata al valore di output $\hat{y}^{(1)} = 15$, mentre la variabile $x^{(2)} = 8$ è legata ad $\hat{y}^{(2)} = 20$. Tale legame è definito dalla retta $\hat{y}^{(i)} = 2.5x^{(i)}$:

Grafico della relazione lineare tra la variabile indipendente x e la variabile dipendente y
Figura 1: Relazione lineare tra la variabile indipendente x e la variabile dipendente y

Cos’è un Equazione Lineare?

Un’equazione lineare è una formula matematica utilizzata per descrivere una relazione lineare fissa tra alcune variabili, ad esempio $x$ e $y$. Una relazione lineare è una relazione in cui quando una variabile cambia, anche l’altra cambia in modo proporzionale. Ad esempio: $y = 2x$. Un’equazione lineare descrive una relazione già nota. In uno spazio a due dimensioni, le relazioni lineari possono essere espresse sia in forma grafica tramite una retta, sia come equazione matematica, nella forma:

$$ \begin{align*} y & = wx + b\\[6pt] \end{align*} $$

oppure, in uno spazio a $n + 1$ dimensioni, con l’ equazione di un iperpiano nella forma:

$$ \begin{align*} y & = w_1x^{(i)}_1 + w_2x^{(i)}_2 + \dots + w_nx^{(i)}_n + b\\[6pt] \end{align*} $$

In un’equazione lineare, la linearità si riferisce sia alla variabile $x$, sia ai parametri $w$ e $b$.

La Funzione di Ipotesi

Guardiamo nuovamente l’esempio Figura 1, relativo all’uso della regressione lineare semplice. Consideriamo l’unica variabile indipendente $x^{(i)}$, e la relazione lineare definita dall’equazione $(1)$:

$$ \hat{y}^{(i)} = wx^{(i)} + b $$

dove:

  • $w$ è il coefficiente angolare o “weight” della variabile indipendente $x$, definita da una singola feature. Il coefficiente $w$ serve per enfatizzare o minimizzare il contributo di $x^{(i)}$ al calcolo del valore di output $\hat{y}^{(i)}$; esso indica quanto cambia $\hat{y}^{(i)}$ per una variazione unitaria di $x^{(i)}$.

  • $b$ è l’intercetta, ovvero il valore di $\hat{y}^{(i)}$ quando la variabile indipendente $x^{(i)}$ è uguale a zero, oppure quando il parametro $w$ è uguale a 0. Il termine $b$, nel contesto del ML è chiamato anche bias, ed è un parametro che regola il punto di partenza della retta di regressione, consentendo al modello di adattarsi meglio ai dati, permettendo alla retta di essere spostata verticalmente.

L’equazione $(1)$ diventa la nostra funzione di ipotesi, cioè la funzione che predice il valore di output del modello a partire dai dati di ingresso $x^{(i)}$. In altre parole, essa associa a ogni input un valore continuo di output. Questa funzione ha lo scopo di approssimare la relazione reale, ma sconosciuta, tra l’input $x^{(i)}$ e l’output osservato $y^{(i)}$, definita dalla cosiddetta funzione target.

L’obiettivo della funzione di ipotesi è quello di approssimare al meglio la relazione nascosta nei dati, che lega l’output reale $y^{(i)}$ all’input $x^{(i)}$, producendo come stima $\hat{y}^{(i)}$.

La funzione di ipotesi può anche essere definita come:

$$ \begin{align*} h\left(x^{(i)}\right) & = \hat{y}^{(i)}\\[6pt] & = wx^{(i)} + b \tag{3}\\[6pt] \end{align*} $$

Ecco l’implementazione via codice:

import numpy as np

def predict(X: np.ndarray, w: float, b: float) -> np.ndarray:
    """Compute predictions for each value in the input array X, giving the weight w and bias b."""
    return w * X + b

Prima di poter usare la funzione di ipotesi in “modo sensato”, è necessario definire i corretti valori dei parametri $w$ e $b$ tramite un processo di addestramento del modello, che vedremo successivamente. Per ora, immaginiamo di sapere che $w = 2.52$ e $b = 4.92$.

Consideriamo ora un dataset formato da $50$ osservazioni distinte $x^{(i)}$, associate ad altrettanti valori label $y^{(i)}$. Se disegnamo sul piano cartesiano $xy$ i punti $\left(x^{(i)}, y^{(i)}\right)$, otteniamo il grafico seguente:

Grafico di un dataset con cinquanta osservazioni
Figura 2: Dataset di 50 osservazioni

Guardando la distribuzione dei punti, possiamo immaginare che la funzione target che ha generato i dati, sia anch’essa una funzione lineare, in quanto essi sembrano distribuiti lungo una retta. Lo scostamento causale delle osservazioni sull’asse $y$ rispetto all’ipotetica funzione target lineare è considerato “rumore” che potrebbe essere stato generato da problemi di misurazione, immissione di dati non corretti nel dataset o da altri fattori.

Possiamo pensare di approssimare la funzione target con una retta $h$ che “passi dentro” alla serie di osservazioni del grafico, nel modo “migliore possibile”, ovvero minimizzando la sommatoria delle distanze verticali (lungo l’asse $y$) tra i singoli punti e la proiezione degli stessi su $h$. Ad esempio:

Grafico di una retta definita dalla funzione di ipotesi
Figura 3: Retta definita dalla funzione di ipotesi

A livello geometrico, la retta $h$ rappresenta ovviamente la nostra funzione di ipotesi $(3)$. Più i punti relativi alle osservazioni si avvicinano alla retta di regressione, più il nostro modello produrrà previsioni precise; la qualità di tale approssimazione dipende quindi dalla “bontà” dei valori dei parametri $w$ e $b$, che permettono di spostare e orientare correttamente la retta $h$ come mostrato in Figura 3 e 4:

Grafico della distanza verticale tra i singoli punti e la proiezione degli stessi sulla retta h
Figura 4: Distanza verticale tra i singoli punti e la proiezione degli stessi sulla retta $h$

Ed è sempre la funzione di ipotesi che, successivamente al training del modello, ci permette di fare previsioni di valori, usando dati mai visti prima, quindi non presenti nel dataset di training. Da notare che le previsioni per un qualsiasi valore $x^{(i)}$ generano sempre punti $\left(x^{(i)}, y^{(i)}\right)$ appartenenti alla retta $h$:

Grafico della predizione di valori del modello di regressione
Figura 5: Predizione di valori usando dati non presenti nel dataset di training
prediction for x = 4 : 15.01
prediction for x = 5 : 17.53
prediction for x = 6 : 20.05

La Funzione di Costo

Prima di trovare un modo per calcolare i valori ottimali dei parametri $w$ e $b$, dobbiamo fare un’altra considerazione. Per capire se la retta $h$ sia una buona approssimazione della funzione target, dobbiamo definire una funzione di costo (cost function, chiamata anche loss function) che indichi, in media, quanto i punti relativi alle osservazioni del dataset siano “vicini” o “lontani” alla retta definita dall’equazione $(3)$, in funzione dei valori correnti dei parametri $w$ e $b$.

In dettaglio, la funzione di costo deve calcolare la media della sommatoria della differenza al quadrato tra ogni label $y^{(i)}$ e la relativa approssimazione $\hat{y}^{(i)}$, per tutte le $m$ osservazioni del dataset di training. Il valore di ritorno sarà un numero reale nell’intervallo $\left[0, \infty\right)$, indicante l’errore o la “distanza” della funzione ipotesi dalla funzione target. Come già detto, più le osservazioni si avvicinano alla retta di regressione, minore sarà l’errore ritornato dalla funzione di costo, e migliore sarà l’approssimazione del nostro modello. Indichiamo con il termine MSE (Mean Squared Error) la seguente funzione di costo:

$$ \begin{align*} \text{MSE}(w, b) & = \frac{1}{m} \sum_{i=1}^{m}\left(y^{(i)} - \hat{y}^{(i)}\right)^2 \tag{4}\\[6pt] & = \frac{1}{m} \sum_{i=1}^{m}\left[y^{(i)} - \left(wx^{(i)} + b\right)\right]^2\\[6pt] \end{align*} $$

dove:

  • $m$ è il numero delle osservazioni del dataset.
  • $y^{(i)}$ è il valore label, o valore target, il valore di ritorno corretto associato al valore di ingresso $x^{(i)}$.
  • $\hat{y}^{(i)}$ è la funzione di ipotesi $(3)$ che calcola la predizione rispetto a $x^{(i)}$.

Se espandiamo la sommatoria, otteniamo:

$$\text{MSE} = \frac{1}{m} \left[\left(y^{(1)} - \hat{y}^{(1)}\right)^2 + \left(y^{(2)} - \hat{y}^{(2)}\right)^2 + \ldots + \left(y^{(m)} - \hat{y}^{(m)}\right)^2\right]$$

Il quadrato della differenza tra $y^{(i)}$ e $\hat{y}^{(i)}$ ritorna sempre un valore non negativo, evitando quindi che, tramite la sommatoria, differenze negative e positive si “annullino” tra di loro. La differenza tra $y^{(i)}$ e $\hat{y}^{(i)}$ rappresenta l’errore della previsione del modello per il valore di ingresso $x^{(i)}$. Se la differenza è zero, la previsione è uguale al valore reale della label.

Ecco l’implementazione via codice:

def loss(X: np.ndarray, y: np.ndarray, w: float, b: float) -> float:
    """Compute the MSE loss value for the input examples."""

    # MSE = 1/m * sum(i=1 to m) { (y^(i) - ŷ^(i))^2 }
    y_hat = predict(X, w, b)
    error = y_hat - y
    squared_error = error ** 2
    loss: float = np.average(squared_error)
    return loss

Proviamo ora ad usare i metodi predict e loss per disegnare il grafico di tre funzioni di ipotesi, ognuna con distinti valori dei parametri $w$ e $b$, e calcolare l’errore relativo a ciasuna di esse:

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

def target_function(n_examples) -> np.ndarray:
    """This is the UNKNOWN real target function. Do not read this code! ;^)"""

    # define the linear function parameters
    w = 2.5
    b = 5.0
    random_coeff = 8

    # generate points for the independent variable
    X = np.sort(np.random.rand(n_examples) * random_coeff)

    # generate the dependent variable with some noise, where the noise is defined by
    # random float values sampled from a univariate "normal" (Gaussian) distribution
    # of mean 0 and variance 1
    noise = np.random.randn(n_examples)
    y = w * X + b + noise
    return X.reshape(-1, 1), y.reshape(-1, 1)

def predict(X: np.ndarray, w: float, b: float) -> np.ndarray:
    """Compute predictions for each value in the input array X, giving the weight w and bias b."""
    return w * X + b

def loss(X: np.ndarray, y: np.ndarray, w: float, b: float) -> float:
    """Compute the MSE cost value for the input examples."""

    # MSE = 1/m * sum(i=1 to m) { (y^(i) - ŷ^(i))^2 }
    y_hat = predict(X, w, b)
    error = y_hat - y
    squared_error = error ** 2
    loss: float = np.average(squared_error)
    return loss

def plot_hypothesis_functions(X, y):
    """Plot three different hypothesis functions, each one using a different set of parameter values"""
    # set some data for parameters w, b and color
    W = [0.2, 1.5, 2.5217]
    B = [0, 1.5, 4.9194]
    C = ["black", "darkgreen", "limegreen"]
    plt.figure(figsize=(8, 5))

    # plot dataset
    plt.scatter(X, y, edgecolor="black", zorder=2, label='Data points')

    # for each tuple (w, b, c):
    for i, (w, b, c) in enumerate(zip(W, B, C)):
        # compute predictions for current parameters w and b
        y_hat = predict(X, w, b)

        # plot hypothesis line
        plt.plot(X, y_hat, zorder=1, color=c, label=f"Hypothesis {i + 1}")

        # annotations
        x_idx = int(X.shape[0] / 2)
        y_hat_idx = int(y_hat.shape[0] / 2)
        plt.text(X[x_idx, 0], y_hat[y_hat_idx] - 1,
                 f"w: {w:.2f}; b: {b:.2f}; loss: {loss(X, y, w, b):.2f}", fontsize=12)
    plt.xlabel('x')
    plt.ylabel('y')
    plt.legend()
    plt.tight_layout()
    plt.show()

np.random.seed(42)
X, y = target_function(50)
plot_hypothesis_functions(X, y)
Plot di tre funzioni di ipotesi, con distinti parametri w e b
Figura 6: Plot di tre funzioni di ipotesi, con distinti parametri $w$ e $b$

Guardando il grafico Figura 6, possiamo notare come la coppia di parametri $\left(w = 2.52, b = 4.92\right)$ permetta alla funzione di ipotesi “Hypothesis 3” di approssimare meglio il dataset, e di ritornare un valore di costo minore (loss: $0.82$) rispetto alle altre rette. Alcune note sul codice:

  • La funzione target_function genera un dataset a partire da una funzione target nota, scelta per comodità. Nella realtà questo non accade: la funzione target è sconosciuta e può essere solo approssimata analizzando i dati osservati. In questo caso la utilizziamo a scopo didattico, per poter lavorare su un esempio semplice e controllato.

  • tutte le funzioni usate (target_function, predict e loss), e tutte quelle future che scriveremo per manipolare array e matrici, utilizzano le funzionalità di Numpy relative al broadcasting e alla vettorizzazione.

    • Il broadcasting è un meccanismo che permette di eseguire operazioni aritmetiche tra array di dimensioni diverse. Quando gli array non hanno la stessa forma, NumPy espande automaticamente quello più piccolo per adattarlo a quello più grande, senza creare copie dei dati. Questo rende i calcoli più efficienti in termini di memoria e velocità.

    • La vettorizzazione consiste invece nell’eseguire operazioni su interi array invece che su singoli elementi. In questo modo si sfruttano le ottimizzazioni hardware dei processori moderni (tecnologia SIMD — Single Instruction, Multiple Data), che permettono di applicare una stessa istruzione a molti valori contemporaneamente. NumPy utilizza la vettorizzazione per eseguire operazioni element-wise in modo molto più rapido rispetto ai cicli espliciti scritti in Python, che risultano più lenti.

    Per maggiori informazioni su questi argomenti, ecco alcuni link:

La domanda ora sorge spontanea: come è possibile calcolare i valori dei parametri $w$ e $b$ che minimizzano l’errore ritornato dalla funzione di costo $(4)$? Scopriamolo nella seconda parte del post.

Precedente
Caricamento
  • Rendering delle formule LaTeX...