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.
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 utilizzato principalmente per prevedere una variabile continua, cioè un valore numerico reale. Dato un insieme di feature in ingresso $x^{(i)}_1, x^{(i)}_2, \dots, x^{(i)}_n$, il modello calcola il valore previsto $\hat{y}^{(i)}$ come una combinazione lineare delle feature:
$$ \hat{y}^{(i)} = w_1x^{(i)}_1 + w_2x^{(i)}_2 + \dots + w_nx^{(i)}_n + b $$dove:
- $x^{(i)}_1, x^{(i)}_2, \dots, x^{(i)}_n$ sono le feature dell’osservazione $i$;
- $w_1, w_2, \dots, w_n$ sono i pesi associati alle feature;
- $b$ è il bias o termine di intercetta;
- $\hat{y}^{(i)}$ è il valore previsto dal modello.
La regressione lineare è detta lineare perché i parametri del modello, cioè i pesi $w_j$ e il bias $b$, compaiono linearmente nell’equazione. Ogni peso $w_j$ determina quanto e in quale direzione la corrispondente feature contribuisce alla previsione, mantenendo costanti le altre feature:
- un peso positivo indica che, all’aumentare della feature, $\hat{y}^{(i)}$ tende ad aumentare;
- un peso negativo indica che, all’aumentare della feature, $\hat{y}^{(i)}$ tende a diminuire;
- un peso vicino a zero indica che la feature ha un effetto lineare ridotto sulla previsione.
Cosa si intende per Modello Lineare?
Un modello lineare descrive la relazione tra le variabili di input e l’output attraverso una funzione lineare nei suoi parametri.
In termini pratici, il modello può essere visto come una funzione parametrica: durante l’addestramento, l’algoritmo stima i valori dei parametri che minimizzano una determinata funzione di errore. Una volta appresi, questi parametri vengono utilizzati per calcolare le previsioni su nuovi dati.
La caratteristica fondamentale di un modello lineare è quindi la linearità rispetto ai parametri $w_1, \dots, w_n$ e al termine di bias $b$. I parametri compaiono alla prima potenza, non vengono moltiplicati tra loro e non sono trasformati mediante funzioni non lineari, come logaritmi o esponenziali. Consideriamo, ad esempio, i seguenti modelli:
$$ \begin{align*} \hat{y}_1 &= w_1x_1 + w_2x_2 + b \\[6pt] \hat{y}_2 &= w_1\log(x) + w_2x^2 + b \end{align*} $$Entrambi sono modelli lineari rispetto ai parametri:
- nel primo caso, l’output è una combinazione lineare delle feature originali;
- nel secondo caso, invece, le feature sono state trasformate mediante $\log(x)$ e $x^2$, ma i parametri $w_1$ e $w_2$ continuano a comparire linearmente.
La linearità del modello, come già detto, riguarda i parametri, non necessariamente le feature. Queste ultime possono essere trasformate, ad esempio mediante potenze, logaritmi o altre funzioni, senza che il modello perda la sua natura lineare rispetto ai parametri.
In una regressione lineare, ogni feature fornisce un contributo additivo alla previsione:
$$ \hat{y} = w_1x_1 + w_2x_2 + \dots + w_nx_n + b $$A parità delle altre feature, ciascun peso $w_j$ descrive come varia la previsione al variare della corrispondente feature $x_j$. Questa struttura relativamente semplice rende i modelli lineari spesso facili da interpretare e computazionalmente efficienti. Infatti, la linearità rispetto ai parametri rende generalmente più semplice l’ottimizzazione dei pesi. Inoltre, quando la relazione da modellare è sufficientemente semplice, possono generalizzare bene anche senza ricorrere a modelli più complessi.
La regressione lineare permette di modellare la relazione tra una o più variabili indipendenti, chiamate feature, e una variabile dipendente continua. In particolare, permette di stimare come varia il valore previsto $\hat{y}^{(i)}$ al variare di una feature, mantenendo costanti le altre.
Un classico esempio è la previsione del prezzo di un immobile in base ad alcune sue caratteristiche, come la metratura, il numero di stanze, l’età o la posizione. In questo caso, il prezzo rappresenta la variabile dipendente $y$, mentre le caratteristiche dell’immobile rappresentano le variabili indipendenti.
Quando utilizziamo una sola feature $x$, parliamo di regressione lineare semplice. Il modello assume che la relazione tra la feature $x$ e il valore previsto $\hat{y}$ possa essere rappresentata mediante una retta. Per una generica osservazione $i$, la previsione è quindi:
$$ \hat{y}^{(i)} = wx^{(i)} + b $$dove:
- $w$ è il peso associato alla feature e determina la pendenza della retta;
- $b$ è il bias, o termine di intercetta, e rappresenta il valore previsto $\hat{y}$ quando $x = 0$.
Il peso $w$ determina quindi quanto varia la previsione al variare della feature. In particolare, un incremento unitario di $x$ produce una variazione di $w$ unità nel valore previsto $\hat{y}$.
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)}$:

Cos’è un’Equazione Lineare?
Un’equazione lineare descrive matematicamente una relazione lineare tra una o più variabili. Nel caso più semplice, considerando due variabili $x$ e $y$, possiamo scrivere:
$$ y = wx + b $$dove:
- $x$ è la variabile indipendente;
- $y$ è la variabile dipendente;
- $w$ determina la pendenza della retta;
- $b$ rappresenta l’intercetta, cioè il valore di $y$ quando $x=0$.
In questo caso, a variazioni uguali di $x$ corrispondono sempre variazioni uguali di $y$. In particolare, se $x$ aumenta di un’unità, $y$ varia di $w$ unità. Ad esempio, nell’equazione:
$$ y = 2x + 1 $$ogni incremento unitario di $x$ produce un incremento di $2$ unità in $y$. In uno spazio bidimensionale, l’equazione $y = wx + b$ rappresenta graficamente una retta. Estendendo lo stesso concetto a $n$ variabili indipendenti, possiamo scrivere l’equazione di un iperpiano:
$$ y = w_1x_1 + w_2x_2 + \dots + w_nx_n + b $$In questo caso, ciascuna variabile $x_j$ compare alla prima potenza e il suo contributo a $y$ è determinato dal corrispondente coefficiente $w_j$.
Un modello lineare è invece lineare nei termini dei suoi parametri. Quindi, sintetizzando:
Equazione lineare:
linearità rispetto alle variabili.Modello lineare:
linearità rispetto ai parametri da apprendere.
La Funzione di Ipotesi
Consideriamo nuovamente l’esempio mostrato in Figura 1, relativo alla regressione lineare semplice. Abbiamo una sola feature $x^{(i)}$ e il valore previsto dal modello è definito dall’equazione:
$$ \hat{y}^{(i)} = wx^{(i)} + b $$dove:
$w$ è il peso (weight) associato alla feature $x$, e rappresenta anche il coefficiente angolare, o pendenza, della retta. Il suo valore determina quanto e in quale direzione $x^{(i)}$ contribuisce alla previsione;
$b$ è l’intercetta, cioè il valore previsto $\hat{y}$ quando $x=0$. Nel contesto del Machine Learning, $b$ viene chiamato anche bias. Geometricamente, determina il punto in cui la retta interseca l’asse verticale, e permette di spostare la retta verso l’alto o verso il basso, consentendo al modello di adattarsi meglio ai dati.
L’equazione diventa quindi la nostra funzione di ipotesi, cioè la funzione utilizzata dal modello per calcolare una previsione $\hat{y}^{(i)}$ a partire dall’input $x^{(i)}$:
$$ h\left(x^{(i)}\right) = wx^{(i)} + b = \hat{y}^{(i)} $$La funzione di ipotesi associa quindi a ogni valore di input un valore continuo di output. Il suo obiettivo è approssimare la relazione reale, ma sconosciuta, che lega l’input $x^{(i)}$ all’output osservato $y^{(i)}$. Tale relazione viene descritta dalla cosiddetta funzione target.
La funzione di ipotesi cerca di approssimare la relazione nascosta nei dati che lega l’input $x^{(i)}$ all’output $y^{(i)}$, producendo come previsione il valore $\hat{y}^{(i)}$.
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 utilizzare la funzione di ipotesi per effettuare previsioni attendibili, è necessario determinare valori appropriati per i parametri $w$ e $b$. Questo avviene attraverso il processo di addestramento del modello, che vedremo successivamente. Per il momento, supponiamo di conoscere già i valori dei parametri:
$$ w = 2.52 \qquad b = 4.92 $$Consideriamo ora un dataset formato da $50$ osservazioni $x^{(i)}$, associate ad altrettanti valori reali $y^{(i)}$, chiamati label. Rappresentando sul piano cartesiano $xy$ i punti:
$$ \left(x^{(i)},y^{(i)}\right) $$otteniamo il grafico seguente:

Osservando la distribuzione dei punti, possiamo ipotizzare che la relazione sottostante tra $x$ e $y$ sia approssimativamente lineare, poiché le osservazioni sembrano distribuirsi attorno a una retta.
I punti, tuttavia, non si trovano esattamente sulla stessa linea. Gli scostamenti possono essere dovuti al rumore, che può derivare da errori di misurazione, variabili non considerate dal modello, variabilità casuale o altri fattori che influenzano il valore osservato $y$.
Possiamo quindi cercare di approssimare la relazione presente nei dati mediante una retta $h$ che passi il più vicino possibile alle osservazioni:

Geometricamente, la retta $h$ rappresenta la nostra funzione di ipotesi:
$$ h\left(x^{(i)}\right) = wx^{(i)} + b = \hat{y}^{(i)} $$Per ogni osservazione $x^{(i)}$, il modello produce quindi una previsione $\hat{y}^{(i)}$. La differenza tra il valore osservato $y^{(i)}$ e il valore previsto $\hat{y}^{(i)}$ prende il nome di residuo:
$$ e^{(i)} = y^{(i)} - \hat{y}^{(i)} $$Graficamente, il residuo corrisponde alla distanza verticale, con segno, tra il punto osservato e la retta di regressione. Durante l’addestramento, il modello cerca i valori di $w$ e $b$ che rendono complessivamente piccoli questi residui. La qualità dell’approssimazione dipende quindi dai valori dei parametri $w$ e $b$, che determinano rispettivamente la pendenza e la posizione verticale della retta $h$, come mostrato in Figura 3 e Figura 4:

Una volta completato l’addestramento, possiamo utilizzare la stessa funzione di ipotesi per effettuare previsioni su nuovi valori di input, mai osservati durante il training. Per ogni nuovo valore $x$, il modello calcola:
$$ \hat{y} = wx+b $$Poiché $\hat{y}$ viene calcolato direttamente dalla funzione di ipotesi, ogni punto previsto $(x,\hat{y})$ appartiene esattamente alla retta $h$:

prediction for x = 4 : 15.01
prediction for x = 5 : 17.53
prediction for x = 6 : 20.05
La Funzione di Costo
Prima di vedere come determinare i valori ottimali dei parametri $w$ e $b$, dobbiamo stabilire come misurare l’errore del modello. Per valutare quanto la retta $h$ approssimi i dati di training, possiamo utilizzare una funzione di costo (cost function), che misura complessivamente la differenza tra i valori reali $y^{(i)}$ e i valori previsti $\hat{y}^{(i)}$ dal modello. Per una singola osservazione, la differenza
$$ y^{(i)}-\hat{y}^{(i)} $$rappresenta il residuo, cioè l’errore commesso dal modello nella previsione dell’osservazione $i$.
Una delle funzioni di costo più utilizzate nella regressione lineare è il Mean Squared Error (MSE), o Errore Quadratico Medio, che calcola la media dei quadrati dei residui sulle $m$ osservazioni del dataset di training:
$$ \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 \end{align*} $$dove:
- $m$ è il numero di osservazioni del dataset di training;
- $y^{(i)}$ è il valore reale, o label, associato all’input $x^{(i)}$;
- $\hat{y}^{(i)}$ è il valore previsto dalla funzione di ipotesi per l’input $x^{(i)}$.
Espandendo la sommatoria, possiamo vedere più chiaramente che il MSE considera l’errore commesso su ogni singola osservazione:
$$ \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 risultato è sempre un numero reale maggiore o uguale a zero:
$$ \text{MSE} \in [0,+\infty) $$Un valore di MSE più piccolo indica che, complessivamente, le previsioni del modello sono più vicine ai valori osservati. Un MSE uguale a zero indica invece che, sul dataset considerato, tutte le previsioni coincidono esattamente con i rispettivi valori reali.
Elevare al quadrato ogni residuo $y^{(i)}-\hat{y}^{(i)}$ produce sempre un valore non negativo. In questo modo, gli errori positivi e negativi non possono annullarsi tra loro quando vengono sommati. Inoltre, il quadrato attribuisce un peso maggiore agli errori più grandi: un errore doppio, ad esempio, produce un errore quadratico quattro volte maggiore. Se $y^{(i)}-\hat{y}^{(i)}=0$, la previsione del modello coincide esattamente con il valore reale dell’osservazione.
L’obiettivo dell’addestramento sarà quindi trovare i valori di $w$ e $b$ che minimizzano la funzione di costo. 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)

Guardando il grafico Figura 6, notiamo 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_functiongenera 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,predicteloss), 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:
- NumPy Basics: Arrays and Vectorized Computation - di Wes McKinney
- Broadcasting - di Wes McKinney
- Look Ma, No for Loops: Array Programming With NumPy - di Brad Solomon
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.