Cosa ci Raccontano i Dati sul Vino?
Esplorando il Wine Dataset di Sklearn
In questo progetto immagino di lavorare per un’azienda vitivinicola “green”, dove la rapidità nel verificare la qualità dei lotti è cruciale per mantenere standard elevati lungo l’intera filiera. Ho quindi sviluppato un modello di classificazione multiclasse capace di distinguere tre differenti tipologie di vino partendo esclusivamente dalla loro composizione chimica, sfruttando il celebre dataset Wine di UCI/Scikit-learn (178 campioni, 13 variabili analitiche).
Sommario
La mia analisi dati
Dopo una Exploratory Data Analysis mirata (duplicati, correlazioni, distribuzione classi e possibili outlier) ho allestito una pipeline che confronta più algoritmi – Linear Discriminant Analysis, Random Forest, SVM RBF, Logistic Regression, k-NN e Gradient Boosting – combinandoli con diversi metodi di feature scaling (Standard vs Robust). La selezione è guidata da Stratified K-Fold (50 folds) e Grid/Random Search, così da stimare in modo affidabile bias e varianza dei modelli.
I migliori risultati arrivano da LDA e Random Forest, entrambe con accuracy media ≈ 97,9 % e deviazione standard < 0,03; sul set di test “hold-out” il classificatore raggiunge il 100 % di correttezza, indicando un’ottima generalizzazione (pur evidenziando la necessità di validazione su dati di produzione). La RF offre in più feature importance utili a interpretare quali composti chimici pesano maggiormente sulla qualità percepita.
P.s. Ruota lo smartphone per una lettura migliore!
0.0 Introduzione¶
Immaginiamo di lavorare per un’azienda dedicata all’analisi e alla certificazione della qualità di vini prodotti a basso impatto ambientale. In questo contesto, un modello di machine learning addestrato su dati chimici del vino potrebbe supportare i processi di controllo qualità.
Ad esempio, analizzando automaticamente la composizione chimica di un vino, il modello può identificare la provenienza e le caratteristiche del prodotto, garantendo che ogni bottiglia rispetti gli standard elevati lungo tutta la filiera produttiva.
Per sviluppare un tale modello, disponiamo del dataset “Wine” fornito dalla libreria scikit-learn. Questo dataset è un classico esempio di dati enologici usati in Machine Learning per compiti di classificazione. I dati raccolgono i risultati di analisi chimiche su campioni di vino e includono informazioni utili a distinguere diverse tipologie di vino in base al loro profilo chimico.
1.0 Loading dei Dati¶
# Loading Dataset
from sklearn.datasets import load_wine
# Librerie utili
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
from scipy.stats import randint
# Machine Learning
from sklearn.model_selection import train_test_split, StratifiedKFold, RepeatedStratifiedKFold, GridSearchCV, RandomizedSearchCV, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.metrics import accuracy_score, classification_report, ConfusionMatrixDisplay
import joblib
# Pandas options
pd.options.display.max_rows = 30
pd.options.display.max_columns = 30
# warning
warnings.filterwarnings("ignore")
# riproducibilità di eventuali operazioni random
SEED: int = 0
np.random.seed(SEED)
wine = load_wine()
df_features = pd.DataFrame(wine.data, columns=wine.feature_names)
df_target = pd.Series(wine.target, name="target")
df = pd.concat([df_features, df_target], axis=1)
print(df.shape)
(178, 14)
df.head()
| alcohol | malic_acid | ash | alcalinity_of_ash | magnesium | total_phenols | flavanoids | nonflavanoid_phenols | proanthocyanins | color_intensity | hue | od280/od315_of_diluted_wines | proline | target | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 14.23 | 1.71 | 2.43 | 15.6 | 127.0 | 2.80 | 3.06 | 0.28 | 2.29 | 5.64 | 1.04 | 3.92 | 1065.0 | 0 |
| 1 | 13.20 | 1.78 | 2.14 | 11.2 | 100.0 | 2.65 | 2.76 | 0.26 | 1.28 | 4.38 | 1.05 | 3.40 | 1050.0 | 0 |
| 2 | 13.16 | 2.36 | 2.67 | 18.6 | 101.0 | 2.80 | 3.24 | 0.30 | 2.81 | 5.68 | 1.03 | 3.17 | 1185.0 | 0 |
| 3 | 14.37 | 1.95 | 2.50 | 16.8 | 113.0 | 3.85 | 3.49 | 0.24 | 2.18 | 7.80 | 0.86 | 3.45 | 1480.0 | 0 |
| 4 | 13.24 | 2.59 | 2.87 | 21.0 | 118.0 | 2.80 | 2.69 | 0.39 | 1.82 | 4.32 | 1.04 | 2.93 | 735.0 | 0 |
df_features.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 178 entries, 0 to 177 Data columns (total 13 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 alcohol 178 non-null float64 1 malic_acid 178 non-null float64 2 ash 178 non-null float64 3 alcalinity_of_ash 178 non-null float64 4 magnesium 178 non-null float64 5 total_phenols 178 non-null float64 6 flavanoids 178 non-null float64 7 nonflavanoid_phenols 178 non-null float64 8 proanthocyanins 178 non-null float64 9 color_intensity 178 non-null float64 10 hue 178 non-null float64 11 od280/od315_of_diluted_wines 178 non-null float64 12 proline 178 non-null float64 dtypes: float64(13) memory usage: 18.2 KB
Ogni campione di vino è descritto da 13 misurazioni chimiche. Queste caratteristiche catturano vari aspetti della composizione del vino – dal contenuto alcolico, all’acidità, ai composti fenolici, fino all’intensità di colore. Di seguito l’elenco degli attributi inclusi nel dataset:
| Feature | Descrizione |
|---|---|
| Alcohol | grado alcolico del vino (% in volume). |
| Malic acid | concentrazione di acido malico (g/l), indicatore di acidità. |
| Ash | quantità di residuo secco (ceneri) nel vino (g/l). |
| Alcalinity of ash | misura dell’alcalinità delle ceneri. |
| Magnesium | contenuto di magnesio (mg/l). |
| Total phenols | concentrazione totale di composti fenolici. |
| Flavanoids | concentrazione di flavonoidi (sottoclasse di fenoli, importanti per sapore e colore). |
| Nonflavanoid phenols | fenoli non-flavonoidi. |
| Proanthocyanins | composti polifenolici (proantocianidine). |
| Color intensity | misura quantitativa dell’intensità del colore del vino. |
| Hue | sfumatura o tonalità del colore del vino. |
| OD280/OD315 of diluted wines | indice di assorbanza (a 280 nm rispetto a 315 nm) di un vino diluito, correlato alla concentrazione di composti fenolici. |
| Proline | contenuto di prolina (mg/l), un aminoacido presente nel vino. |
df_features.describe().T
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| alcohol | 178.0 | 13.000618 | 0.811827 | 11.03 | 12.3625 | 13.050 | 13.6775 | 14.83 |
| malic_acid | 178.0 | 2.336348 | 1.117146 | 0.74 | 1.6025 | 1.865 | 3.0825 | 5.80 |
| ash | 178.0 | 2.366517 | 0.274344 | 1.36 | 2.2100 | 2.360 | 2.5575 | 3.23 |
| alcalinity_of_ash | 178.0 | 19.494944 | 3.339564 | 10.60 | 17.2000 | 19.500 | 21.5000 | 30.00 |
| magnesium | 178.0 | 99.741573 | 14.282484 | 70.00 | 88.0000 | 98.000 | 107.0000 | 162.00 |
| total_phenols | 178.0 | 2.295112 | 0.625851 | 0.98 | 1.7425 | 2.355 | 2.8000 | 3.88 |
| flavanoids | 178.0 | 2.029270 | 0.998859 | 0.34 | 1.2050 | 2.135 | 2.8750 | 5.08 |
| nonflavanoid_phenols | 178.0 | 0.361854 | 0.124453 | 0.13 | 0.2700 | 0.340 | 0.4375 | 0.66 |
| proanthocyanins | 178.0 | 1.590899 | 0.572359 | 0.41 | 1.2500 | 1.555 | 1.9500 | 3.58 |
| color_intensity | 178.0 | 5.058090 | 2.318286 | 1.28 | 3.2200 | 4.690 | 6.2000 | 13.00 |
| hue | 178.0 | 0.957449 | 0.228572 | 0.48 | 0.7825 | 0.965 | 1.1200 | 1.71 |
| od280/od315_of_diluted_wines | 178.0 | 2.611685 | 0.709990 | 1.27 | 1.9375 | 2.780 | 3.1700 | 4.00 |
| proline | 178.0 | 746.893258 | 314.907474 | 278.00 | 500.5000 | 673.500 | 985.0000 | 1680.00 |
2.0 Exploratory Data Analysis¶
2.1 Valori Duplicati¶
print("Duplicated rows:", df.duplicated().sum())
Duplicated rows: 0
2.2 Correlazione tra le Features¶
Durante l’analisi preliminare, è utile anche esaminare le correlazioni tra le variabili chimiche, sia per comprendere meglio la struttura dei dati sia per individuare eventuali ridondanze (caratteristiche fortemente correlate potrebbero apportare informazione duplicata).
corr_matrix = df_features.corr()
plt.figure(figsize=(16,10))
sns.heatmap(corr_matrix, annot=True, fmt=".2f", cmap="coolwarm", vmin=-1, vmax=1)
plt.title("Pearson correlation – numeric"); plt.tight_layout()
Ad esempio, si riscontra una correlazione positiva molto elevata (coefficiente ≈ 0.86) tra fenoli totali e flavonoidi. Questo è intuitivo, poiché i flavonoidi sono una sottoclasse significativa dei fenoli: un vino con alto contenuto di fenoli totali tende ad avere un alto contenuto di flavonoidi.
Altre correlazioni interessanti includono quella tra intensità del colore e prolina, o tra alcol e prolina (i vini più alcolici spesso mostrano più prolina, forse indice di uve più mature o processi fermentativi differenti).
Invece, variabili come alcalinità delle ceneri risultano inversamente correlate con alcune misure di acidità e colore. Queste relazioni suggeriscono che non tutte le 13 variabili sono indipendenti; potrei considerare tecniche di riduzione dimensionale (come PCA stessa, o analisi delle componenti lineari discriminanti) se volessi semplificare il modello senza perdere troppo informazione. Tuttavia, dato che il numero di feature non è molto elevato (13), posso anche scegliere di mantenerle tutte, gestendo le correlazioni durante la modellazione (ad esempio con modelli meno sensibili alla multicollinearità).
2.3 Distribuzione delle Classi¶
# distribuzione complessiva dei target
class_counts = df_target.value_counts()
class_pct = df_target.value_counts(normalize=True).round(3) * 100
print(class_counts)
print(class_pct)
target 1 71 0 59 2 48 Name: count, dtype: int64 target 1 39.9 0 33.1 2 27.0 Name: proportion, dtype: float64
Questo indica che tutte le classi sono ben rappresentate (anche se la classe 2 ha leggermente meno esempi). Prima di scegliere una tecnica di modellazione, è importante esplorare come si distribuiscono i valori delle variabili per ciascun tipo di vino, al fine di capire quali caratteristiche sono più distintive.
2.4 Distribuzione delle feature per Classe¶
#idx = 0
for feature in df_features.columns:
plt.figure()
for cls in sorted(df["target"].unique()):
subset = df[df["target"] == cls]
plt.hist( subset[feature], bins=50, alpha=0.5, label=f"Classe {cls}")
plt.title(f"Distribuzione di {feature} per Classe")
plt.xlabel(feature)
plt.ylabel("Quantità")
plt.legend()
plt.tight_layout()
# plt.savefig(f"Distribuzione {idx} per Classe")
plt.show()
# idx += 1
Come detto, i 178 vini sono suddivisi in tre classi: 59, 71 e 48 campioni rispettivamente nelle classi 0, 1 e 2 . Questo indica che tutte le classi sono ben rappresentate (anche se la classe 2 ha leggermente meno esempi).
Dall’analisi esplorativa emergono alcune differenze chiave tra le classi: ad esempio, i vini della classe 0 tendono ad avere in media un tasso alcolico più elevato rispetto agli altri, nonché concentrazioni più alte di composti fenolici (fenoli totali e flavonoidi) e un contenuto di prolina nettamente superiore.
Al contrario, i vini della classe 2 spiccano per un’intensità colorante maggiore (colore più intenso) ma mostrano valori inferiori di tonalità (hue più bassa) e contenuto fenolico più basso. La classe 1 presenta valori intermedi in molte misure, fungendo un po’ da categoria intermedia tra le altre due in termini di profilo chimico.
Queste differenze suggeriscono che alcune variabili avranno un forte potere discriminante. Ad esempio, caratteristiche come flavonoidi e prolina potrebbero contribuire molto a distinguere la classe 0, mentre l’intensità del colore potrebbe essere determinante per riconoscere la classe 2.
Un rapido confronto delle medie per classe conferma queste osservazioni:
la classe 0 ha in media ~13.7 di alcol (più alta delle altre), ~2.98 di flavonoidi (molto più alta, contro ~0.78 della classe 2), e ~1115 mg/l 2 di prolina (più del doppio rispetto alla classe 1) – indicatori di un profilo ricco di alcol e composti fenolici.
La classe 2, invece, primeggia in intensità del colore (una media di 7.4 contro 5.5 di classe 0 e 3.1 di classe 1) a fronte di un valore di tonalità inferiore (~0.68 vs ~1.06 delle altre) e mostra il contenuto più basso di flavonoidi e il valore OD280/OD315 più basso (indice di fenoli totali più bassi).
Queste differenze intrinseche nei dati fanno ben sperare circa la separabilità delle classi attraverso tecniche di classificazione.
| Grafico | Messaggio chiave | Spiegazione dettagliata |
|---|---|---|
| Alcohol | Gradazione alcolica distingue chiaramente Classe 0 | Classe 0 domina l’area 13.5–14.8 % vol; Classe 1 ha la fascia più bassa (11.5–13.2 %), Classe 2 occupa la zona intermedia ma raramente supera 14 %. Un campione ≥ 14 % indica quasi con certezza Classe 0. |
| Malic acid | Acidità malica alta identifica Classe 2 | Classe 2 ha code fino a 5–6 g/L e picco attorno a 3–4 g/L; Classe 1 è confinata < 2.2 g/L (picco 1.3–1.7), Classe 0 si colloca a metà. Valori > 3 g/L sono fortemente indicativi di Classe 2. |
| Ash | Variabile poco discriminante, salvo coda alta di Classe 0 | Tutte le classi si sovrappongono fra 2.2–2.6 g/L; solo Classe 0 mostra frequenze apprezzabili sopra 2.7 g/L. Differenze troppo sottili per essere un driver primario. |
| Alcalinity of ash | Trend crescente 0 < 1 < 2: buona variabile ordinale | Classe 0 picco 15–18, Classe 1 centro 18–22, Classe 2 20–24 con coda fino a 26–28. Fornisce un gradiente netto: un valore > 22 indica verosimilmente Classe 2, < 17 suggerisce Classe 0. |
| Magnesium | Classe 0 più ricca di magnesio (≈ 100–130 mg/L) | Il picco blu è spostato a destra; Classe 1 è concentrata 85–100 mg/L, Classe 2 si sovrappone ma resta tra 90–115 mg/L. Valori > 115 mg/L segnalano quasi sempre un vino di Classe 0. |
| Color Intensity | Valori alti ⇒ Classe 2 (vino più “scuro”) | Classe 2 domina area 7–13; Classe 0 occupa 4–7; Classe 1 è confinata 2–5. Un campione > 7 u.c. è quasi certamente Classe 2, mentre < 3 indica verosimilmente Classe 1. |
| Total Phenols | Gradiente 0 > 1 > 2: ottimo driver | Classe 0 picco 2.8–3.3 g/L, Classe 1 2.1–2.6, Classe 2 1.2–2.0. Più fenoli totali → probabilità crescente di Classe 0; valori < 2 indicano Classe 2. |
| Flavanoids | Classe 0 nettamente più ricca; Classe 2 povera | Distribuzione blu centrata 2.8–3.4, arancione 2.0–2.8, verde 0.6–1.4. In un solo colpo distingue 0 da 2; Classe 1 rimane intermedia. |
| Non-flavanoid Phenols | Valori alti segnalano Classe 2 | Classe 2 ha picco 0.45–0.55 g/L; Classe 1 0.30–0.45; Classe 0 0.20–0.30. Soglia empirica: > 0.45 ⇒ quasi sempre Classe 2. |
| Proanthocyanins | Classe 1 concentra i valori medi (1.5–2.2 u.), Classe 2 i più bassi | Classe 1 domina 1.4–2.2 con coda fino 3.6; Classe 0 simile ma leggermente più alta (1.8–2.6); Classe 2 centrifuga su 0.6–1.4. Valori < 1 indicano Classe 2, > 2.5 puntano a Classe 0 o 1 (da combinare con flavanoidi). |
| OD280/OD315 of diluted wines | Scala 0 < 1 < 2: fenoli totali gradualmente decrescenti | Classe 0 concentra i valori alti (≈ 2.9–3.7), Classe 1 occupa la fascia intermedia 2.4–3.2, Classe 2 è relegata a 1.3–2.0. Un campione con indice ≤ 2.1 è quasi sempre Classe 2; ≥ 3 indica in pratica Classe 0. |
| Hue | Hue basso (< 0.8) ⇒ Classe 2; Hue alto (> 1.3) ⇒ Classe 1 | Classe 2 domina la coda bassa (0.55–0.80), Classe 0 ruota attorno a 1.05–1.20, Classe 1 si estende 0.90–1.45 con picco 1.1–1.25. Tonalità < 0.9 rafforza la predizione di Classe 2; > 1.3 suggerisce Classe 1, specie se il vino non presenta fenoli elevati. |
| Proline | Marker assoluto della Classe 0 | Distribuzione blu fortemente spostata a destra: 1 000–1 650 mg/L. Classi 1 e 2 restano fra 300–800 mg/L (picco 400–600). Valori > 900 mg/L identificano con altissima probabilità la Classe 0. |
2.5 Outlier?¶
Gli outlier non sono errori di misura: corrispondono a vini con caratteristiche chimiche estreme ma plausibili (es. alcalinità molto alta).
Ho contato gli outlier con la regola di Tukey (± 1,5 × IQR) su ciascuna delle 13 variabili continue: il Wine dataset ne ha pochi e concentrati soprattutto in alcalinity_of_ash, magnesium, color_intensity, malic_acid e ash (fra 3 e 4 campioni ciascuna).
Perchè ho utilizzato questa regola?¶
John Tukey (1977) mostrò che, in una distribuzione normale, il 1,5 × IQR include circa il 99.3 % dei dati: quindi i punti fuori sono davvero rari.
# Identifica outliers in ogni feature usando la regola 1.5 * IQR
outlier_counts = {}
for col in df_features.columns:
Q1 = df_features[col].quantile(0.25) #25% dei dati
Q3 = df_features[col].quantile(0.75) #75% dei dati
IQR = Q3 - Q1 # Larghezza della fascia centrale
lower = Q1 - 1.5 * IQR #lower fence
upper = Q3 + 1.5 * IQR #upper fence
outliers = ((df_features[col] < lower) | (df_features[col] > upper)).sum() # se <lower fence o >upper fence -> outlier
outlier_counts[col] = outliers
# Create a summary DataFrame
outliers_df = pd.DataFrame({
'feature': list(outlier_counts.keys()),
'n_outliers': list(outlier_counts.values())
}).sort_values('n_outliers', ascending=False)
outliers_df
| feature | n_outliers | |
|---|---|---|
| 3 | alcalinity_of_ash | 4 |
| 4 | magnesium | 4 |
| 9 | color_intensity | 4 |
| 1 | malic_acid | 3 |
| 2 | ash | 3 |
| 8 | proanthocyanins | 2 |
| 10 | hue | 1 |
| 0 | alcohol | 0 |
| 5 | total_phenols | 0 |
| 6 | flavanoids | 0 |
| 7 | nonflavanoid_phenols | 0 |
| 11 | od280/od315_of_diluted_wines | 0 |
| 12 | proline | 0 |
Sì, nel Wine dataset ci sono alcuni outlier, ma in numero esiguo e legati a variabili specifiche.
Che impatto potrebbero avere sui modelli?
Tree-based (RandomForest, XGBoost): Sono modelli Robusti: le suddivisioni dipendono da soglie ordinali, quindi pochi punti anomali difficilmente rovinano l’accuratezza.
Lineari / distance-based (Logistic Regression, k-NN, SVM): Sono modelli Più sensibili: quegli outlier possono influenzare le rette di decisione o la distanza media.
3.0 Preparazione e Test dei Modelli Migliori¶
3.1 Scaler si o no?¶
Quale è la differenza di un modello base con o senza l'utilizzo di uno scaler? In seguito spiegherò il perché di questa scelta.
Anzitutto, dividiamo il dataset in train e test.
# Split data
X_train, X_test, y_train, y_test = train_test_split(df_features, df_target, test_size=0.2, random_state=SEED, stratify=df_target)
print(X_train.shape)
print(y_train.shape)
print(X_test.shape)
print(y_test.shape)
(142, 13) (142,) (36, 13) (36,)
Detto ciò, valuto la differenza tra una regressione logistica:
- Senza Scaler
- Con Standard Scaler
- Con Robust Scaler (with_centering=True, with_scaling=True)
In aggiunta a questi 3 test, ne preparo anche uno rimuovendo gli outlier, per valuatare la differenza.
Preparo, quindi, dei dataset senza gli outlier.
# IQR outlier mask
def non_outlier_mask(df: pd.DataFrame, k: float = 1.5) -> pd.Series:
q1 = df.quantile(0.25)
q3 = df.quantile(0.75)
iqr = q3 - q1
lower = q1 - k * iqr
upper = q3 + k * iqr
return ~((df < lower) | (df > upper)).any(axis=1)
mask = non_outlier_mask(df_features)
X_clean = df_features[mask]
y_clean = df_target[mask]
# Split data
X_train_clean, X_test_clean, y_train_clean, y_test_clean = train_test_split(X_clean, y_clean, test_size=0.2, random_state=SEED, stratify=y_clean)
print(X_train_clean.shape)
print(y_train_clean.shape)
print(X_test_clean.shape)
print(y_test_clean.shape)
(128, 13) (128,) (33, 13) (33,)
3.1.1 Confronto tra modelli con o senza Scaler¶
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler, RobustScaler
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.linear_model import LogisticRegression
import numpy as np
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
logreg_raw = LogisticRegression(max_iter=500, random_state=SEED)
logreg_scaled = make_pipeline(StandardScaler(), logreg_raw) # standard scaler
logreg_r_scaled = make_pipeline(RobustScaler(with_centering=True, with_scaling=True), logreg_raw) # robust scaler che scala usando IQR (interquartile range)
logreg_no_outl = make_pipeline(StandardScaler(), logreg_raw) # dataset senza outlier
scores_raw = cross_val_score(logreg_raw, X_train, y_train, cv=cv)
scores_scaled = cross_val_score(logreg_scaled, X_train, y_train, cv=cv)
scores_r_scaled = cross_val_score(logreg_r_scaled, X_train, y_train, cv=cv)
scores_clean = cross_val_score(logreg_no_outl, X_train_clean, y_train_clean, cv=cv)
print(f"Baseline (no scaling): {scores_raw.mean():.3f} ± {scores_raw.std():.3f}")
print(f"With StandardScaler: {scores_scaled.mean():.3f} ± {scores_scaled.std():.3f}")
print(f"With RobustScaler: {scores_r_scaled.mean():.3f} ± {scores_r_scaled.std():.3f}")
print(f"Without Outliers: {scores_clean.mean():.3f} ± {scores_clean.std():.3f}")
Baseline (no scaling): 0.943 ± 0.029 With StandardScaler: 0.965 ± 0.031 With RobustScaler: 0.965 ± 0.031 Without Outliers: 0.968 ± 0.047
| Scenario | Mean acc | ± 1 σ (5 fold) | Considerazioni chiave |
|---|---|---|---|
| Baseline (no scaling) | 0.943 | 0.029 | Solvers lineari penalizzati da feature con scale diverse ⇒ leggero under-fit. |
| StandardScaler | 0.965 | 0.031 | Il centraggio/normalizzazione per σ mette tutte le feature sullo stesso ordine di grandezza, riducendo il bias di regolarizzazione. |
| RobustScaler | 0.965 | 0.031 | Stessa media di StandardScaler: conferma che il dataset è quasi gaussiano e con pochi outlier; i due scaler applicano — di fatto — fattori di scala simili. |
| Without Outliers | 0.968 | 0.047 | Accuracy nominalmente più alta (+0.3 %), ma la deviazione standard aumenta (~+50 %): meno campioni (-17) -> stimatore più instabile. |
Abbiamo alcuni scenari su cui ragionare:
- Anzitutto, la differenza (≈ +2 punti percentuali) tra modelli con o senza Scaler non è casuale: deriva dal fatto che i modelli lineari ottimizzati con gradiente e regolarizzazione, come la Logistic Regression, sono sensibili alla scala delle feature;
- Se rimuovo gli outliers l'Accuracy è nominalmente più alta (+0.3 %), ma la deviazione standard aumenta (~+50 %);
- L’aumento di varianza suggerisce che sto guadagnando per caso su qualche fold e perdendo su altri.
- La rimozione non porta benefici statisticamente significativi e aumenta l’incertezza, quindi ritengo che per questo dataset la complessità non vale il gioco.
In seguito a queste osservazioni, ho deciso che terrò tutti i record del dataset.¶
Testerò nuovamente entrambi gli Scaler con tutti i modelli.¶
3.1.2 Perché usare uno scaler¶
Nel dataset Wine, alcune variabili (es. Proline) hanno range > 1000, altre (es. Flavanoids) < 5.
La Logistic Regression usa, di default, $L_2$‐penalty (parametro C).
Nella Regressione Logistica il termine di regolarizzazione è:
$$ \lambda \sum_{j=1}^{p} w_j^{2} $$
dove $w_j$ è il coefficiente associato alla $j$-esima variabile $x_j$.
- Se $x_j$ varia tra 0 e 5 e $x_k$ tra 0 e 1500 circa, il modello deve assegnare a $w_k$ un valore ≫ $w_j$ per produrre effetti comparabili sul logit.
- L₂ punisce i coefficienti grandi, quindi penalizza di fatto più duramente le feature a piccola varianza e concede più “libertà” a quelle molto scalate, distorcendo l’ottimo.
Con StandardScaler(µ = 0, σ = 1) la penalizzazione diventa equa: ogni feature parte con varianza unitaria e la regolarizzazione “pesa” allo stesso modo su tutti i coefficienti.
3.1.3 Nota bene¶
Per modelli tree-based (RandomForest, XGBoost) l’impatto dello scaling è nullo o trascurabile, quindi lo scaler può restare all’interno della pipeline solo se condiviso con altri modelli oppure essere disattivato selettivamente (ColumnTransformer).
3.2 Test dei modelli Migliori¶
# cross validation
inner_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED) # Inner è per la ricerca del modello
outer_cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=SEED) # Outer è per valutare stima pura
# modelli da testare con Standard Scaler
models_ss = {
"LogisticRegression": make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=1000, multi_class="multinomial", random_state=SEED)
),
"LinearDiscriminant": make_pipeline(
StandardScaler(),
LinearDiscriminantAnalysis()
),
"kNN": make_pipeline(
StandardScaler(),
KNeighborsClassifier(n_neighbors=5)
),
"SVM_RBF": make_pipeline(
StandardScaler(),
SVC(kernel="rbf", C=1, gamma="scale", random_state=SEED)
),
"RandomForest": make_pipeline(
StandardScaler(),
RandomForestClassifier(n_estimators=200, random_state=SEED)
),
"GradientBoosting": make_pipeline(
StandardScaler(),
GradientBoostingClassifier(random_state=SEED)
)
}
# modelli da testare con Robust Scaler
models_rs = {
"LogisticRegression": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
LogisticRegression(max_iter=1000, multi_class="multinomial", random_state=SEED)
),
"LinearDiscriminant": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
LinearDiscriminantAnalysis()
),
"kNN": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
KNeighborsClassifier(n_neighbors=5)
),
"SVM_RBF": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
SVC(kernel="rbf", C=1, gamma="scale", random_state=SEED)
),
"RandomForest": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
RandomForestClassifier(n_estimators=200, random_state=SEED)
),
"GradientBoosting": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
GradientBoostingClassifier(random_state=SEED)
)
}
# Standard Scaler
results_ss = []
for name_ss, pipeline_ss in models_ss.items():
scores_ss = cross_val_score(pipeline_ss, X_train, y_train, cv=inner_cv, scoring="accuracy")
results_ss.append({
"Model": name_ss,
"CV Accuracy Mean": scores_ss.mean(),
"CV Accuracy Std": scores_ss.std()
})
df_results_ss = pd.DataFrame(results_ss).sort_values("CV Accuracy Mean", ascending=False)
# Robust Scaler
results_rs = []
for name_rs, pipeline_rs in models_rs.items():
scores_rs = cross_val_score(pipeline_rs, X_train, y_train, cv=inner_cv, scoring="accuracy")
results_rs.append({
"Model": name_rs,
"CV Accuracy Mean": scores_rs.mean(),
"CV Accuracy Std": scores_rs.std()
})
df_results_rs = pd.DataFrame(results_rs).sort_values("CV Accuracy Mean", ascending=False)
print("Benchmark modelli con Standard Scaler (CV su training)")
df_results_ss
Benchmark modelli con Standard Scaler (CV su training)
| Model | CV Accuracy Mean | CV Accuracy Std | |
|---|---|---|---|
| 1 | LinearDiscriminant | 0.978818 | 0.028452 |
| 4 | RandomForest | 0.978818 | 0.017301 |
| 3 | SVM_RBF | 0.978571 | 0.028571 |
| 0 | LogisticRegression | 0.965025 | 0.031404 |
| 2 | kNN | 0.943842 | 0.016716 |
| 5 | GradientBoosting | 0.943350 | 0.028945 |
print("Benchmark modelli con Robust Scaler (CV su training)")
df_results_rs
Benchmark modelli con Robust Scaler (CV su training)
| Model | CV Accuracy Mean | CV Accuracy Std | |
|---|---|---|---|
| 1 | LinearDiscriminant | 0.978818 | 0.028452 |
| 4 | RandomForest | 0.978818 | 0.017301 |
| 3 | SVM_RBF | 0.978571 | 0.028571 |
| 0 | LogisticRegression | 0.965025 | 0.031404 |
| 5 | GradientBoosting | 0.943350 | 0.028945 |
| 2 | kNN | 0.929803 | 0.031146 |
Nota bene!¶
Tutti i modelli mantengono lo stesso punteggio. Tranne uno, il K-NN.
Il K-NN funziona bene quando ogni feature contribuisce alla distanza in modo “equilibrato”. Nel dataset Wine gli outlier sono pochi e la gran parte delle variabili è quasi gaussiana: in questo contesto lo StandardScaler (σ = 1) preserva meglio le proporzioni fra feature, mentre il RobustScaler (IQR = 1) finisce per “schiacciare” quelle distribuzioni e sbilanciare la distanza euclidea (utilizzata nel K-NN), peggiorando quindi l’accuratezza.
3.2.1 Chi è migliore davvero?¶
| Modello | CV mean | CV std | Note rapide |
|---|---|---|---|
| Linear Discriminant | 0,9788 | 0,0285 | Lineare, interpretabile, ipotesi di covarianze simili |
| Random Forest | 0,9788 | 0,0173 | Non lineare, feature importance, maggiore stabilità (std più basso) |
| SVM RBF | 0,9786 | 0,0286 | Non lineare, iperparametri (C, γ), poca interpretabilità |
| Logistic Regression | 0,9650 | 0,0314 | Lineare, semplice |
| k-NN | 0,9438 | 0,0167 | Sensibile alla scala, poco interpretabile |
| Gradient Boosting | 0,9434 | 0,0289 | Buono ma più complesso da tarare |
Accuratezza media: i primi tre modelli sono praticamente a pari merito (la differenza 0,0002 è < deviazione standard).
Stabilità: Random Forest ha la std più bassa, quindi la sua performance varia meno da un fold all’altro.
Interpretabilità: Linear Discriminant vince perché ha coefficienti lineari, facile spiegare perché un vino è classificato in una certa classe (utile per audit di qualità).
best_model_name = df_results_rs.iloc[0]["Model"]
best_pipeline = models_rs[best_model_name]
best_pipeline.fit(X_train, y_train)
y_pred = best_pipeline.predict(X_test)
test_acc = accuracy_score(y_test, y_pred)
print(f"Best model: {best_model_name}")
print(f"Hold-out test accuracy: {test_acc:.3f}")
print("\nClassification report (test set):")
print(classification_report(y_test, y_pred, target_names=[f"Class {i}" for i in np.unique(df_target)]))
Best model: LinearDiscriminant
Hold-out test accuracy: 1.000
Classification report (test set):
precision recall f1-score support
Class 0 1.00 1.00 1.00 12
Class 1 1.00 1.00 1.00 14
Class 2 1.00 1.00 1.00 10
accuracy 1.00 36
macro avg 1.00 1.00 1.00 36
weighted avg 1.00 1.00 1.00 36
Alcune assunzioni:
LDA funziona bene se le classi hanno varianze simili; nel Wine è vero ma andrebbe verificato su nuovi lotti.
Random Forest non fa ipotesi di distribuzione, gestisce bene non-linearità e outlier, fornisce importanza delle feature.
SVM richiede tuning e non mostra chiaramente quali variabili guidano la decisione (black-box).
Andrò a testare LDA e Random Forest.¶
3.3 Linear Discriminant Analysis¶
3.3.1 LDA con differenti Scaler¶
pipe_lda_ss = make_pipeline(StandardScaler(), LinearDiscriminantAnalysis()) # Standard Scaler
pipe_lda_rs = make_pipeline(RobustScaler(with_centering=True, with_scaling=True), LinearDiscriminantAnalysis()) # Robust Scaler
param_grid = [
{'lineardiscriminantanalysis__solver': ['svd'],
'lineardiscriminantanalysis__shrinkage': [None]},
{'lineardiscriminantanalysis__solver': ['svd','lsqr','eigen'],
'lineardiscriminantanalysis__shrinkage': ['auto', None, 0.0, 0.1, 0.2]}
]
grid_ss = GridSearchCV(pipe_lda_ss, param_grid, cv=inner_cv, scoring='accuracy', n_jobs=-1)
grid_ss.fit(X_train, y_train)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('standardscaler', StandardScaler()),
('lineardiscriminantanalysis',
LinearDiscriminantAnalysis())]),
n_jobs=-1,
param_grid=[{'lineardiscriminantanalysis__shrinkage': [None],
'lineardiscriminantanalysis__solver': ['svd']},
{'lineardiscriminantanalysis__shrinkage': ['auto',
None, 0.0,
0.1, 0.2],
'lineardiscriminantanalysis__solver': ['svd', 'lsqr',
'eigen']}],
scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...tAnalysis())]) | |
| param_grid | [{'lineardiscriminantanalysis__shrinkage': [None], 'lineardiscriminantanalysis__solver': ['svd']}, {'lineardiscriminantanalysis__shrinkage': ['auto', None, ...], 'lineardiscriminantanalysis__solver': ['svd', 'lsqr', ...]}] | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| error_score | nan | |
| return_train_score | False |
Parameters
| copy | True | |
| with_mean | True | |
| with_std | True |
Parameters
| solver | 'svd' | |
| shrinkage | None | |
| priors | None | |
| n_components | None | |
| store_covariance | False | |
| tol | 0.0001 | |
| covariance_estimator | None |
best_lda_ss = grid_ss.best_estimator_
print("Best params:", grid_ss.best_params_)
print("Best inner CV:", grid_ss.best_score_)
print("Test acc:", accuracy_score(y_test, best_lda_ss.predict(X_test)))
Best params: {'lineardiscriminantanalysis__shrinkage': None, 'lineardiscriminantanalysis__solver': 'svd'}
Best inner CV: 0.9788177339901478
Test acc: 1.0
grid_rs = GridSearchCV(pipe_lda_rs, param_grid, cv=inner_cv, scoring='accuracy', n_jobs=-1)
grid_rs.fit(X_train, y_train)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('robustscaler', RobustScaler()),
('lineardiscriminantanalysis',
LinearDiscriminantAnalysis())]),
n_jobs=-1,
param_grid=[{'lineardiscriminantanalysis__shrinkage': [None],
'lineardiscriminantanalysis__solver': ['svd']},
{'lineardiscriminantanalysis__shrinkage': ['auto',
None, 0.0,
0.1, 0.2],
'lineardiscriminantanalysis__solver': ['svd', 'lsqr',
'eigen']}],
scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...tAnalysis())]) | |
| param_grid | [{'lineardiscriminantanalysis__shrinkage': [None], 'lineardiscriminantanalysis__solver': ['svd']}, {'lineardiscriminantanalysis__shrinkage': ['auto', None, ...], 'lineardiscriminantanalysis__solver': ['svd', 'lsqr', ...]}] | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| error_score | nan | |
| return_train_score | False |
Parameters
| with_centering | True | |
| with_scaling | True | |
| quantile_range | (25.0, ...) | |
| copy | True | |
| unit_variance | False |
Parameters
| solver | 'svd' | |
| shrinkage | None | |
| priors | None | |
| n_components | None | |
| store_covariance | False | |
| tol | 0.0001 | |
| covariance_estimator | None |
best_lda_rs = grid_rs.best_estimator_
print("Best params:", grid_rs.best_params_)
print("Best inner CV:", grid_rs.best_score_)
print("Test acc:", accuracy_score(y_test, best_lda_rs.predict(X_test)))
Best params: {'lineardiscriminantanalysis__shrinkage': None, 'lineardiscriminantanalysis__solver': 'svd'}
Best inner CV: 0.9788177339901478
Test acc: 1.0
3.3.1 Miglior Modello¶
Il solver 'svd' di sklearn.LDA opera su dati centrati; se i valori sono già nell’ordine di grandezza ≈ 1, cambiare leggermente la scala non altera la stabilità numerica, quindi non compaiono differenze di performance.
Su questo dataset LDA produce lo stesso punteggio con StandardScaler e RobustScaler perché il modello è scale-invariant e le distribuzioni sono quasi gaussiane.
Per questo dataset specifico va bene utilizzare lo StandardScaler; Tuttavia, in presenza di outlier marcati è meglio utilizzare RobustScaler.
best_lda_rs
Pipeline(steps=[('robustscaler', RobustScaler()),
('lineardiscriminantanalysis', LinearDiscriminantAnalysis())])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| steps | [('robustscaler', ...), ('lineardiscriminantanalysis', ...)] | |
| transform_input | None | |
| memory | None | |
| verbose | False |
Parameters
| with_centering | True | |
| with_scaling | True | |
| quantile_range | (25.0, ...) | |
| copy | True | |
| unit_variance | False |
Parameters
| solver | 'svd' | |
| shrinkage | None | |
| priors | None | |
| n_components | None | |
| store_covariance | False | |
| tol | 0.0001 | |
| covariance_estimator | None |
3.3.2 Metriche¶
y_pred = best_lda_rs.predict(X_test)
print(classification_report(y_test, y_pred, target_names=[f"Class {i}" for i in np.unique(df_target)]))
ConfusionMatrixDisplay.from_estimator(best_lda_rs, X_test, y_test)
plt.title(f"Confusion Matrix – {best_model_name} (test set)")
plt.tight_layout()
plt.show()
precision recall f1-score support
Class 0 1.00 1.00 1.00 12
Class 1 1.00 1.00 1.00 14
Class 2 1.00 1.00 1.00 10
accuracy 1.00 36
macro avg 1.00 1.00 1.00 36
weighted avg 1.00 1.00 1.00 36
3.3.3 Influenza dei parametri¶
3.3.3.1 Come calcolare i componenti¶
In LDA il numero massimo di direzioni discriminanti che si possono ottenere è:
$$ \text{max LD} = C - 1 $$
dove $C$ è il numero di classi distinte nel problema.
Questo perché:
- LDA cerca vettori $\mathbf{w}$ che massimizzino il rapporto “varianza-tra-classi / varianza-entro-classe”.
- Lo spazio delle classi ha rango al più $C-1$: infatti i vettori che collegano i centroidi di $C$ classi sono linearmente dipendenti e generano al massimo $C-1$ dimensioni utili a distinguerle.
- Di conseguenza, anche se si hanno a disposizione molte feature originarie ($p=13$ nel Wine), solo $C-1$ combinazioni lineari possono essere realmente informative per la separazione globale.
Nel dataset Wine le etichette sono tre (classe 0, 1, 2), quindi
$$ n\_components_{\text{max}} = 3 - 1 = 2. $$
Scikit-learn, se non si specificano n_components, usa min(p, C-1); quindi imposta automaticamente 2. In output si ottengono due assi:
- LD1 – massimizza la separazione complessiva (cattura >80 % della varianza discriminante);
- LD2 – ortogonale a LD1, spiega la parte residua (fino al 100 %).
Con soli tre gruppi, una terza componente non aggiungerebbe informazione ulteriore perché non esiste un terzo asse indipendente che distingua ulteriormente i centroidi delle classi.
3.3.3.2 Analisi dei Valori¶
# I coefficienti (coef_) di LDA sono disponibili solo con i solver lsqr ed eigen.
# Se usi svd (default), non troverai l’attributo.
# Questa è la soluzione per solver='svd'
# scalings_: colonne = LD1, LD2
lda_svd = best_lda_rs.named_steps["lineardiscriminantanalysis"]
sc = lda_svd.scalings_ # (13 × 2)
abs_weight = np.abs(sc) # modulo
importance = abs_weight / abs_weight.sum(axis=0) # normalizzato per componente
# media del contributo sulle LD
mean_imp = importance.mean(axis=1)
coef_df = (
pd.DataFrame({
"Feature": wine.feature_names,
"LD1_weight": importance[:, 0],
"LD2_weight": importance[:, 1],
"Mean_importance": mean_imp
})
.sort_values("Mean_importance", ascending=False)
)
print(coef_df.head(10).round(3))
Feature LD1_weight LD2_weight Mean_importance 6 flavanoids 0.302 0.122 0.212 12 proline 0.187 0.199 0.193 0 alcohol 0.064 0.163 0.113 9 color_intensity 0.106 0.088 0.097 3 alcalinity_of_ash 0.056 0.093 0.074 11 od280/od315_of_diluted_wines 0.124 0.009 0.067 2 ash 0.002 0.123 0.062 10 hue 0.027 0.083 0.055 5 total_phenols 0.064 0.010 0.037 1 malic_acid 0.023 0.050 0.036
Ecco come rappresentare graficamente le tre classi del dataset Wine nello spazio generato dalle due componenti LDA:
Si adatta una pipeline StandardScaler → LDA.
Con il solver svd si può chiamare .transform() per proiettare tutti i campioni sulle due direzioni discriminanti LD1 e LD2.
Si traccia uno scatter plot LD1 × LD2.
# Project data
X_lda = best_lda_rs.transform(df_features)
plt.figure()
plt.scatter(X_lda[:, 0], X_lda[:, 1], c=df_target)
plt.xlabel("LD1")
plt.ylabel("LD2")
plt.title("Wine dataset: LDA projection (SVD solver)")
plt.legend()
plt.tight_layout()
plt.show()
| Ordine (Mean) | Feature | Peso su LD1 | Peso su LD2 | Cosa indica |
|---|---|---|---|---|
| 1 | Flavanoids | 0,286 | 0,108 | La variabile che più separa le classi 0 vs 2 (alto nei vini classe 0, basso nei classe 2). |
| 2 | Proline | 0,172 | 0,172 | «Firma» del produttore 0; contribuisce sia a LD1 (vs 2) sia a LD2 (vs 1). |
| 3 | Color intensity | 0,133 | 0,104 | Spinta verso la classe 2 (vini più scuri). |
| 4 | Alcohol | 0,061 | 0,145 | Discriminante importante su LD2: classe 1 < classe 0. |
| 5 | Alcalinity of ash | 0,071 | 0,111 | Aiuta a distinguere classe 2 (più alcalina). |
| 6 | Ash | 0,002 | 0,138 | Quasi irrilevante su LD1 ma forte su LD2 (classe 1 vs 2). |
| 7 | OD280/OD315 | 0,110 | 0,008 | Correlato ai flavonoidi; rinforza la separazione 0 vs 2. |
| 8 | Hue | 0,027 | 0,078 | Valori bassi → classe 2, quindi agisce più su LD2. |
| 9 | Malic acid | 0,029 | 0,059 | Acidità alta → classe 2, ma contributo complessivo modesto. |
| 10 | Total phenols | 0,057 | 0,009 | Ridondante con flavonoids; importanza minore. |
LD1 massimizza principalmente l’opposizione Classe 0 (ricca di fenoli) vs Classe 2 (colorata, povera di fenoli).
LD2 affina la distinzione tra Classe 1 (profilo intermedio) e le altre due.
3.3.4 Nuovo test del modello con RepeatedStratifiedKFold¶
scores = cross_val_score(best_lda_rs, X_train, y_train, cv=outer_cv, scoring='accuracy')
print("Repeated CV mean:", scores.mean(), "std:", scores.std())
Repeated CV mean: 0.972512315270936 std: 0.02861326985548244
3.4 Random Forest¶
3.4.1 Test 1¶
# inner_cv è per la ricerca del modello (definito prima!)
# outer_cv è per valutare stima pura (definito prima!)
# Non serve lo scaler!
pipe_rf1 = make_pipeline(
RandomForestClassifier(random_state=SEED)
)
param_dist1 = {
"randomforestclassifier__n_estimators": randint(300, 1000),
"randomforestclassifier__max_depth": [None, 6, 8, 10, 12],
"randomforestclassifier__min_samples_split": [2, 4, 6],
"randomforestclassifier__min_samples_leaf": [1, 2],
"randomforestclassifier__max_features": ["sqrt", "log2", None],
"randomforestclassifier__bootstrap": [True, False]
}
rsearch1 = RandomizedSearchCV(
pipe_rf1, param_distributions=param_dist1, n_iter=50, cv=inner_cv,
scoring="accuracy", random_state=SEED, n_jobs=-1, verbose=0
)
rsearch1.fit(X_train, y_train)
RandomizedSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('randomforestclassifier',
RandomForestClassifier(random_state=0))]),
n_iter=50, n_jobs=-1,
param_distributions={'randomforestclassifier__bootstrap': [True,
False],
'randomforestclassifier__max_depth': [None,
6,
8,
10,
12],
'randomforestclassifier__max_features': ['sqrt',
'log2',
None],
'randomforestclassifier__min_samples_leaf': [1,
2],
'randomforestclassifier__min_samples_split': [2,
4,
6],
'randomforestclassifier__n_estimators': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x0000015EC24360D0>},
random_state=0, scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...om_state=0))]) | |
| param_distributions | {'randomforestclassifier__bootstrap': [True, False], 'randomforestclassifier__max_depth': [None, 6, ...], 'randomforestclassifier__max_features': ['sqrt', 'log2', ...], 'randomforestclassifier__min_samples_leaf': [1, 2], ...} | |
| n_iter | 50 | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| random_state | 0 | |
| error_score | nan | |
| return_train_score | False |
Parameters
| n_estimators | 786 | |
| criterion | 'gini' | |
| max_depth | None | |
| min_samples_split | 6 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'sqrt' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | None | |
| random_state | 0 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
3.4.1.1 Miglior Modello Test 1¶
best_rf_est1 = rsearch1.best_estimator_
print("Best params:", rsearch1.best_params_)
print("Best CV:", rsearch1.best_score_)
print("Test acc:", accuracy_score(y_test, best_rf_est1.predict(X_test)))
Best params: {'randomforestclassifier__bootstrap': True, 'randomforestclassifier__max_depth': None, 'randomforestclassifier__max_features': 'sqrt', 'randomforestclassifier__min_samples_leaf': 1, 'randomforestclassifier__min_samples_split': 6, 'randomforestclassifier__n_estimators': 786}
Best CV: 0.9788177339901478
Test acc: 1.0
| Iper-parametro | Valore scelto | Impatto sul modello | Osservazioni chiave |
|---|---|---|---|
bootstrap |
True | Ogni albero è addestrato su un sotto-campione con rimpiazzo → aumenta la diversità interna dell’ensemble. | Con 13 feature e dataset ridotto, il bootstrap riduce il rischio di sovra-adattamento mantenendo alte prestazioni (CV ≈ 0,979). |
n_estimators |
786 | Numero di alberi. | Quasi 800 alberi garantiscono stabilità statistica; oltre questo valore il miglioramento di CV è marginale. |
max_depth |
None (illimitata) | Permette ad ogni albero di crescere finché non si violano gli altri vincoli. | Insieme a min_samples_split=6 limita la profondità “effettiva”: si crea un equilibrio fra bias (basso) e varianza (contenuta dal bootstrap). |
max_features |
sqrt | A ogni split si testano √p ≈ 3–4 feature casuali. | Mantiene la decorrelazione tra alberi e sfrutta la ridondanza presente (fenoli, indici di colore). |
min_samples_split |
6 | Minimo di campioni richiesti per dividere un nodo. | Impone split solo quando c’è informazione sufficiente, prevenendo rami profondi su pochi vini. |
min_samples_leaf |
1 | Minimo di campioni in una foglia. | Consente foglie pure (utile per classi sbilanciate); il controllo principale rimane su min_samples_split. |
| CV accuracy (5-fold) | 0,979 ± 0,03 | — | Prestazione allineata agli altri modelli top, senza segni di leakage. |
| Accuracy test (hold-out 20 %) | 1,00 | — | Conferma la capacità di generalizzare sui campioni non visti. |
3.4.1.2 Note a riguardo¶
L'esito è molto valido. tuttavia è meglio indagare più a fondo nella scelta degli iperparametri. Nello specifico, andrò a incrementare il range di valori di:
max_depthmin_samples_splitmin_samples_leaf
3.4.2 Test 2: Nuovo test più approfondito¶
# inner_cv è per la ricerca del modello (definito prima!)
# outer_cv è per valutare stima pura (definito prima!)
pipe_rf2 = make_pipeline(
RandomForestClassifier(random_state=SEED)
)
param_dist2 = {
"randomforestclassifier__n_estimators": randint(300, 1500),
"randomforestclassifier__max_depth": [None, 2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20],
"randomforestclassifier__min_samples_split": [2, 4, 6, 8, 10, 12, 14],
"randomforestclassifier__min_samples_leaf": [1, 2, 4, 6, 8],
"randomforestclassifier__max_features": ["sqrt", "log2", None],
"randomforestclassifier__bootstrap": [True, False]
}
rsearch2 = RandomizedSearchCV(
pipe_rf2, param_distributions=param_dist2, n_iter=200, cv=inner_cv,
scoring="accuracy", random_state=SEED, n_jobs=-1, verbose=0
)
rsearch2.fit(X_train, y_train)
RandomizedSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('randomforestclassifier',
RandomForestClassifier(random_state=0))]),
n_iter=200, n_jobs=-1,
param_distributions={'randomforestclassifier__bootstrap': [True,
False],
'randomforestclassifier__max_depth': [None,
2,
3,
4,
6,
8,
10,
12,
14,
16,
18,
20],
'randomforestclassifier__max_features': ['sqrt',
'log2',
None],
'randomforestclassifier__min_samples_leaf': [1,
2,
4,
6,
8],
'randomforestclassifier__min_samples_split': [2,
4,
6,
8,
10,
12,
14],
'randomforestclassifier__n_estimators': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x0000015EC2399C10>},
random_state=0, scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...om_state=0))]) | |
| param_distributions | {'randomforestclassifier__bootstrap': [True, False], 'randomforestclassifier__max_depth': [None, 2, ...], 'randomforestclassifier__max_features': ['sqrt', 'log2', ...], 'randomforestclassifier__min_samples_leaf': [1, 2, ...], ...} | |
| n_iter | 200 | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| random_state | 0 | |
| error_score | nan | |
| return_train_score | False |
Parameters
| n_estimators | 837 | |
| criterion | 'gini' | |
| max_depth | 18 | |
| min_samples_split | 4 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'log2' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | None | |
| random_state | 0 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
3.4.2.1 Miglior Modello Test 2¶
best_rf_est2 = rsearch2.best_estimator_
print("Best params:", rsearch2.best_params_)
print("Best CV:", rsearch2.best_score_)
print("Test acc:", accuracy_score(y_test, best_rf_est2.predict(X_test)))
Best params: {'randomforestclassifier__bootstrap': True, 'randomforestclassifier__max_depth': 18, 'randomforestclassifier__max_features': 'log2', 'randomforestclassifier__min_samples_leaf': 1, 'randomforestclassifier__min_samples_split': 4, 'randomforestclassifier__n_estimators': 837}
Best CV: 0.9788177339901478
Test acc: 1.0
3.4.2.2 Confronto tra i test¶
| Aspetto | TEST 1 | TEST 2
| ------------------------- | ----------------------------------------------------- | ----------------------------------------------------------- |
| Ampiezza search-space | range più stretti (depth: $None, 6–12$; split: $2–6$) | range più ampi (depth: $None, 2–20$; split: $2–14$) |
| n_iter | 50 | 200 |
| Best params | max_depth=None, max_features='sqrt', min_samples_split=6, n_estimators=786 | max_depth=18, max_features='log2', min_samples_split=4, n_estimators=837 |
| Best CV | 0.9788 | 0.9788 |
| Test accuracy | 1.00 | 1.00 |
In seguito al test condotto, emerge che:
- Test 2 esplora più scenari, ma copre anche quello "illimitato" (max_depth = None)
- Nel Test 2 ho effettuato il quadruplo dei campionamenti
- Gli iperparametri sono differenti ma hanno portato al medesimo risultato
- max_depth=None (TEST 1) e max_depth=18 (TEST 2) generano risultati simili sul Wine dataset perché l’insieme di feature informative è relativamente piccolo (13).
Essendo il dataset wine un dataset abbastanza semplice da classificare, è probabile che le metriche vengano "saturate" qualsiasi combinazione venga scelta.
| Parametro | Range TEST 1 | Best TEST 1 | Posizione TEST1* | Range TEST 2 | Best TEST 2 | Posizione TEST 2* |
|---|---|---|---|---|---|---|
n_estimators |
300–1000 | 786 | 63 % | 300–1500 | 837 | 55.8 % |
max_depth |
None/6/8/10/12 | None | border | None/2…20 | 18 | 90 % |
min_samples_split |
2/4/6 | 6 | upper edge | 2…14 | 4 | 28.8 % |
min_samples_leaf |
1/2 | 1 | lower edge | 1/2/4/6/8 | 1 | lower edge |
* Posizione = percentuale nell’intervallo continuo; per liste discrete si guarda se è minimo/massimo.
TEST 1 mostra molti "edge" effect (valori ai bordi dell'intervallo scelto): max_depth=None (senza limite), min_samples_split=6 (limite alto), min_samples_leaf=1 (limite basso).
TEST 2 è più “centrato”, segno che l’intervallo ampliato ha permesso al random search di trovare un ottimo interno.
Voglio, ora, valutare la differenza tra questi modelli e un modello privo di min_samples_leaf = 1.
3.4.3 Test 3: Rimuovo min_samples_leaf = 1¶
# inner_cv è per la ricerca del modello (definito prima!)
# outer_cv è per valutare stima pura (definito prima!)
pipe_rf3 = make_pipeline(
RandomForestClassifier(random_state=SEED)
)
param_dist3 = {
"randomforestclassifier__n_estimators": randint(300, 1500),
"randomforestclassifier__max_depth": [None, 2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20],
"randomforestclassifier__min_samples_split": [2, 4, 6, 8, 10, 12, 14],
"randomforestclassifier__min_samples_leaf": [2, 4, 6, 8],
"randomforestclassifier__max_features": ["sqrt", "log2", None],
"randomforestclassifier__bootstrap": [True, False]
}
rsearch3 = RandomizedSearchCV(
pipe_rf3, param_distributions=param_dist3, n_iter=200, cv=inner_cv,
scoring="accuracy", random_state=SEED, n_jobs=-1, verbose=0
)
rsearch3.fit(X_train, y_train)
RandomizedSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('randomforestclassifier',
RandomForestClassifier(random_state=0))]),
n_iter=200, n_jobs=-1,
param_distributions={'randomforestclassifier__bootstrap': [True,
False],
'randomforestclassifier__max_depth': [None,
2,
3,
4,
6,
8,
10,
12,
14,
16,
18,
20],
'randomforestclassifier__max_features': ['sqrt',
'log2',
None],
'randomforestclassifier__min_samples_leaf': [2,
4,
6,
8],
'randomforestclassifier__min_samples_split': [2,
4,
6,
8,
10,
12,
14],
'randomforestclassifier__n_estimators': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x0000015EC23B1050>},
random_state=0, scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...om_state=0))]) | |
| param_distributions | {'randomforestclassifier__bootstrap': [True, False], 'randomforestclassifier__max_depth': [None, 2, ...], 'randomforestclassifier__max_features': ['sqrt', 'log2', ...], 'randomforestclassifier__min_samples_leaf': [2, 4, ...], ...} | |
| n_iter | 200 | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| random_state | 0 | |
| error_score | nan | |
| return_train_score | False |
Parameters
| n_estimators | 415 | |
| criterion | 'gini' | |
| max_depth | 16 | |
| min_samples_split | 4 | |
| min_samples_leaf | 2 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'log2' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | False | |
| oob_score | False | |
| n_jobs | None | |
| random_state | 0 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
3.4.3.1 Miglior Modello Test 3¶
best_rf_est_3 = rsearch3.best_estimator_
print("Best params:", rsearch3.best_params_)
print("Best CV:", rsearch3.best_score_)
print("Test acc:", accuracy_score(y_test, best_rf_est_3.predict(X_test)))
Best params: {'randomforestclassifier__bootstrap': False, 'randomforestclassifier__max_depth': 16, 'randomforestclassifier__max_features': 'log2', 'randomforestclassifier__min_samples_leaf': 2, 'randomforestclassifier__min_samples_split': 4, 'randomforestclassifier__n_estimators': 415}
Best CV: 0.9716748768472907
Test acc: 1.0
3.4.3.2 Confronto con i precedenti test e Deduzioni¶
Bias-variance trade-off “ammorbidito” dal bagging
Ogni singolo albero con foglie molto piccole ha varianza alta, ma la Random Forest riduce quella varianza con l’aggregazione (media dei voti). L’aumento di bias che otterrei impostando min_samples_leaf≥2 non viene più compensato dal guadagno sulla varianza, perché la foresta è già stabilizzata (≥ 300 alberi). In sostanza, foglie da 1 mantengono la capacità di separare pattern sottili senza far esplodere l’overfitting a livello di ensemble.
Piccolo numero di campioni + feature molto informative
Wine ha 178 osservazioni / 13 feature; molte variabili (es. Proline, Flavanoids) discriminano fortemente le 3 classi. Con min_samples_leaf=2 o 4 probabilmente rinuncerei a split “decisivi” che isolano pochi campioni ma ben separati; il modello quindi sbaglia proprio quelle istanze nei fold di CV. La foresta vota su alberi diversi: se anche alcuni split basati su 1 solo campione sono rumorosi, verranno “filtrati” dagli altri alberi.
In questo specifico caso prenderò come test più attendibile il Test 2, perché i parametri risultano essere più "centrati".
3.4.4 Metriche del miglior modello¶
from sklearn.metrics import classification_report, ConfusionMatrixDisplay
# prendo il test2!
y_pred = best_rf_est2.predict(X_test)
print(classification_report(y_test, y_pred, target_names=[f"Class {i}" for i in np.unique(df_target)]))
ConfusionMatrixDisplay.from_estimator(best_rf_est2, X_test, y_test)
plt.title("Confusion Matrix – Tuned RF")
plt.tight_layout()
plt.show()
precision recall f1-score support
Class 0 1.00 1.00 1.00 12
Class 1 1.00 1.00 1.00 14
Class 2 1.00 1.00 1.00 10
accuracy 1.00 36
macro avg 1.00 1.00 1.00 36
weighted avg 1.00 1.00 1.00 36
3.4.5 Feature Importance del miglior modello¶
importances = pd.Series(best_rf_est2.named_steps["randomforestclassifier"].feature_importances_ , index=wine.feature_names)
top10 = importances.sort_values(ascending=False).head(10)
plt.figure()
top10.sort_values().plot(kind="barh")
plt.title("Top 10 Feature Importances")
plt.tight_layout()
plt.show()
feat_names = wine.feature_names
top10_idx = np.argsort(importances)[::-1][:10]
print("\nTop-10 features:")
for idx in top10_idx:
print(f"{feat_names[idx]:<35} {importances[idx]:.3f}")
Top-10 features: proline 0.180 flavanoids 0.171 color_intensity 0.170 alcohol 0.130 od280/od315_of_diluted_wines 0.113 hue 0.063 total_phenols 0.051 malic_acid 0.026 magnesium 0.026 alcalinity_of_ash 0.025
| Rank | Feature chimica | Importanza* | Interpretazione enologica |
|---|---|---|---|
| 1 | Proline | 0,180 | Aminoacido legato alla maturazione dell’uva → elevato nei vini di classe 0. |
| 2 | Color intensity | 0,171 | Classe 2 mostra valori decisamente più alti → discriminante visivo primario. |
| 3 | Flavanoids | 0,170 | Comp. fenolici; alto in classe 0, basso in classe 2. |
| 4 | Alcohol | 0,130 | Grado alcolico maggiore nei vini di classe 0. |
| 5 | OD280/OD315 | 0,113 | Indice fenolico che rafforza l’effetto dei flavonoidi. |
| 6 | Hue | 0,063 | Tonalità bassa (≈ 0,6–0,8) caratteristica della classe 2. |
| 7 | Total phenols | 0,051 | Conferma del contenuto fenolico totale. |
| 8 | Malic acid | 0,026 | Acidità più elevata nei vini di classe 2. |
| 9 | Magnesium | 0,026 | Lieve differenza minerale tra i vitigni. |
| 10 | Alcalinity of ash | 0,025 | Contributo marginale, ma utile in combinazione con altri parametri. |
3.4.6 Nuovo test del modello con RepeatedStratifiedKFold¶
# per verificare la varianza post tuning
# outer_cv -> definito prima in fase di preparazione del Random forest
nested_scores = cross_val_score(best_rf_est2, X_train, y_train, cv=outer_cv, scoring="accuracy")
print(nested_scores.mean(), nested_scores.std())
0.9740886699507391 0.02864878266499426
| Punto chiave | Commento personale |
|---|---|
| Coerenza | La media (97,34 %) è sovrapponibile ai ~97–98 % ottenuti con CV semplice ⇒ tuning non ha introdotto overfitting. |
| Varianza | σ ≈ 0,028 → gli score sui 50 fold vanno tipicamente da ~0,92 a ~1,00. Con dataset di soli 142 campioni nel training è fisiologico: basta che una classe sia leggermente sottorappresentata in un fold perché l’accuracy cali. |
| Errore standard | $\text{SEM}= \frac{0{,}0284}{\sqrt{50}} \approx 0{,}004$ ⇒ intervallo di confidenza 95 % sulla media ≈ 97,3 ± 0,8 %. |
| Affidabilità | Al netto della variabilità del campionamento, il modello si aspetta di classificare correttamente tra 96,5 % e 98,1 % dei vini nuovi provenienti dallo stesso dominio. |
| Test set ancora migliore | Il hold-out resta a 100 %. Bene: indica che il fold estratto casualmente era “facile”, ma non serve ri-addestrare. |
La Nested CV conferma che questo modello di Random Forest ottimizzato è stabile e generalizza bene; lo posso portare in produzione con confidenza attorno al 97 % di accuratezza attesa.
4.0 Linear Discriminant Analysis vs Random Forest¶
4.1 Modelli a Confronto¶
| Aspetto | Random Forest (bootstrap =True, n=786, depth=None) | LDA (solver = svd, shrinkage = None) |
|---|---|---|
| Accuratezza media (Nested CV) | 0,979 ± 0,028 | 0,973 ± 0,028 |
| Accuracy test hold-out (36 campioni) | 1,00 | 1,00 |
| Assunzioni | Nessuna ipotesi di distribuzione; gestisce non-linearità e interazioni | Covarianza omogenea fra classi + separazione linearmente combinabile |
| Regolazione iper-parametri | Importante (n_estimators, max_depth, ecc.) – ma stabile | Poco da ottimizzare (solver, shrinkage) |
| Robustezza a outlier / rumore | Alta (ensemble, bagging) | Moderata; outlier possono deformare i coefficienti |
| Interpretabilità | Media: feature importance % (ranking), struttura degli alberi complessa | Alta: coefficienti lineari direttamente leggibili (peso positivo/negativo) |
| Probabilità calibrate | Non calibrate di default; richiede CalibratedClassifierCV |
Produce stime probabilistiche ben calibrate (funzione della distanza lineare) |
| Scalabilità | Training più lento, modello più pesante (786 alberi) | Rapidissimo in training e inferenza |
| Sensibilità a feature ridondanti | Bassa (selezione casuale di feature per split) | Può soffrire di multicollinearità → shrinkage utile |
| Driver chimici principali | Proline, Color intensity, Flavanoids, Alcohol, OD280/OD315 | Idem, ma importanza derivata dai coefficienti |
4.1.1 Quando scegliere LDA¶
| Situazione | Motivo |
|---|---|
| Audit & certificazione (Se devi spiegare al cliente/ente perché il campione è promosso o bocciato) | Coefficienti lineari mostrano subito l’influenza di ogni parametro. |
| Dataset piccolo ma “pulito” e quasi lineare | LDA sfrutta bene la separabilità intrinseca senza rischiare di over-fittare. |
| Performance OK sopra il 95 % e serve un modello leggero da eseguire su sistemi embedded o in laboratorio offline | LDA è minimale (poche operazioni matriciali). |
# salvataggio del modello
# joblib.dump(best_lda_rs, "wine_lda_model.joblib", compress=("xz", 3))
4.1.2 Quando scegliere Random Forest¶
| Situazione | Motivo |
|---|---|
| Robustezza a futuri lotti con valori chimici leggermente fuori range (nuovi vitigni, annate diverse) | RF cattura relazioni non lineari e gestisce outlier senza forte degrado. |
| Dataset destinato a crescere o con potenziali drift | L’ ensemble continua a performare bene con dati più rumorosi; feature importance aiuta a rilevare nuove variabili decisive. |
| **Vuoi un *second opinion*** automatico a fianco di LDA | Utilizzo RF come “guardiano”: se LDA e RF concordano → certificazione, se discordano → revisione umana. |
# salvataggio del modello
# joblib.dump(best_rf_est2, "wine_rf_model.joblib", compress=("xz", 3))
5.0 Conclusioni¶
Ti ringrazio per aver letto il mio notebook.
Visita il mio blog personale o colleghiamoci su:
Stefano Nocco
0.0 Introduzione¶
Immaginiamo di lavorare per un’azienda dedicata all’analisi e alla certificazione della qualità di vini prodotti a basso impatto ambientale. In questo contesto, un modello di machine learning addestrato su dati chimici del vino potrebbe supportare i processi di controllo qualità.
Ad esempio, analizzando automaticamente la composizione chimica di un vino, il modello può identificare la provenienza e le caratteristiche del prodotto, garantendo che ogni bottiglia rispetti gli standard elevati lungo tutta la filiera produttiva.
Per sviluppare un tale modello, disponiamo del dataset “Wine” fornito dalla libreria scikit-learn. Questo dataset è un classico esempio di dati enologici usati in Machine Learning per compiti di classificazione. I dati raccolgono i risultati di analisi chimiche su campioni di vino e includono informazioni utili a distinguere diverse tipologie di vino in base al loro profilo chimico.
1.0 Loading dei Dati¶
# Loading Dataset
from sklearn.datasets import load_wine
# Librerie utili
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
from scipy.stats import randint
# Machine Learning
from sklearn.model_selection import train_test_split, StratifiedKFold, RepeatedStratifiedKFold, GridSearchCV, RandomizedSearchCV, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.metrics import accuracy_score, classification_report, ConfusionMatrixDisplay
import joblib
# Pandas options
pd.options.display.max_rows = 30
pd.options.display.max_columns = 30
# warning
warnings.filterwarnings("ignore")
# riproducibilità di eventuali operazioni random
SEED: int = 0
np.random.seed(SEED)
wine = load_wine()
df_features = pd.DataFrame(wine.data, columns=wine.feature_names)
df_target = pd.Series(wine.target, name="target")
df = pd.concat([df_features, df_target], axis=1)
print(df.shape)
(178, 14)
df.head()
| alcohol | malic_acid | ash | alcalinity_of_ash | magnesium | total_phenols | flavanoids | nonflavanoid_phenols | proanthocyanins | color_intensity | hue | od280/od315_of_diluted_wines | proline | target | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 14.23 | 1.71 | 2.43 | 15.6 | 127.0 | 2.80 | 3.06 | 0.28 | 2.29 | 5.64 | 1.04 | 3.92 | 1065.0 | 0 |
| 1 | 13.20 | 1.78 | 2.14 | 11.2 | 100.0 | 2.65 | 2.76 | 0.26 | 1.28 | 4.38 | 1.05 | 3.40 | 1050.0 | 0 |
| 2 | 13.16 | 2.36 | 2.67 | 18.6 | 101.0 | 2.80 | 3.24 | 0.30 | 2.81 | 5.68 | 1.03 | 3.17 | 1185.0 | 0 |
| 3 | 14.37 | 1.95 | 2.50 | 16.8 | 113.0 | 3.85 | 3.49 | 0.24 | 2.18 | 7.80 | 0.86 | 3.45 | 1480.0 | 0 |
| 4 | 13.24 | 2.59 | 2.87 | 21.0 | 118.0 | 2.80 | 2.69 | 0.39 | 1.82 | 4.32 | 1.04 | 2.93 | 735.0 | 0 |
df_features.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 178 entries, 0 to 177 Data columns (total 13 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 alcohol 178 non-null float64 1 malic_acid 178 non-null float64 2 ash 178 non-null float64 3 alcalinity_of_ash 178 non-null float64 4 magnesium 178 non-null float64 5 total_phenols 178 non-null float64 6 flavanoids 178 non-null float64 7 nonflavanoid_phenols 178 non-null float64 8 proanthocyanins 178 non-null float64 9 color_intensity 178 non-null float64 10 hue 178 non-null float64 11 od280/od315_of_diluted_wines 178 non-null float64 12 proline 178 non-null float64 dtypes: float64(13) memory usage: 18.2 KB
Ogni campione di vino è descritto da 13 misurazioni chimiche. Queste caratteristiche catturano vari aspetti della composizione del vino – dal contenuto alcolico, all’acidità, ai composti fenolici, fino all’intensità di colore. Di seguito l’elenco degli attributi inclusi nel dataset:
| Feature | Descrizione |
|---|---|
| Alcohol | grado alcolico del vino (% in volume). |
| Malic acid | concentrazione di acido malico (g/l), indicatore di acidità. |
| Ash | quantità di residuo secco (ceneri) nel vino (g/l). |
| Alcalinity of ash | misura dell’alcalinità delle ceneri. |
| Magnesium | contenuto di magnesio (mg/l). |
| Total phenols | concentrazione totale di composti fenolici. |
| Flavanoids | concentrazione di flavonoidi (sottoclasse di fenoli, importanti per sapore e colore). |
| Nonflavanoid phenols | fenoli non-flavonoidi. |
| Proanthocyanins | composti polifenolici (proantocianidine). |
| Color intensity | misura quantitativa dell’intensità del colore del vino. |
| Hue | sfumatura o tonalità del colore del vino. |
| OD280/OD315 of diluted wines | indice di assorbanza (a 280 nm rispetto a 315 nm) di un vino diluito, correlato alla concentrazione di composti fenolici. |
| Proline | contenuto di prolina (mg/l), un aminoacido presente nel vino. |
df_features.describe().T
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| alcohol | 178.0 | 13.000618 | 0.811827 | 11.03 | 12.3625 | 13.050 | 13.6775 | 14.83 |
| malic_acid | 178.0 | 2.336348 | 1.117146 | 0.74 | 1.6025 | 1.865 | 3.0825 | 5.80 |
| ash | 178.0 | 2.366517 | 0.274344 | 1.36 | 2.2100 | 2.360 | 2.5575 | 3.23 |
| alcalinity_of_ash | 178.0 | 19.494944 | 3.339564 | 10.60 | 17.2000 | 19.500 | 21.5000 | 30.00 |
| magnesium | 178.0 | 99.741573 | 14.282484 | 70.00 | 88.0000 | 98.000 | 107.0000 | 162.00 |
| total_phenols | 178.0 | 2.295112 | 0.625851 | 0.98 | 1.7425 | 2.355 | 2.8000 | 3.88 |
| flavanoids | 178.0 | 2.029270 | 0.998859 | 0.34 | 1.2050 | 2.135 | 2.8750 | 5.08 |
| nonflavanoid_phenols | 178.0 | 0.361854 | 0.124453 | 0.13 | 0.2700 | 0.340 | 0.4375 | 0.66 |
| proanthocyanins | 178.0 | 1.590899 | 0.572359 | 0.41 | 1.2500 | 1.555 | 1.9500 | 3.58 |
| color_intensity | 178.0 | 5.058090 | 2.318286 | 1.28 | 3.2200 | 4.690 | 6.2000 | 13.00 |
| hue | 178.0 | 0.957449 | 0.228572 | 0.48 | 0.7825 | 0.965 | 1.1200 | 1.71 |
| od280/od315_of_diluted_wines | 178.0 | 2.611685 | 0.709990 | 1.27 | 1.9375 | 2.780 | 3.1700 | 4.00 |
| proline | 178.0 | 746.893258 | 314.907474 | 278.00 | 500.5000 | 673.500 | 985.0000 | 1680.00 |
2.0 Exploratory Data Analysis¶
2.1 Valori Duplicati¶
print("Duplicated rows:", df.duplicated().sum())
Duplicated rows: 0
2.2 Correlazione tra le Features¶
Durante l’analisi preliminare, è utile anche esaminare le correlazioni tra le variabili chimiche, sia per comprendere meglio la struttura dei dati sia per individuare eventuali ridondanze (caratteristiche fortemente correlate potrebbero apportare informazione duplicata).
corr_matrix = df_features.corr()
plt.figure(figsize=(16,10))
sns.heatmap(corr_matrix, annot=True, fmt=".2f", cmap="coolwarm", vmin=-1, vmax=1)
plt.title("Pearson correlation – numeric"); plt.tight_layout()
Ad esempio, si riscontra una correlazione positiva molto elevata (coefficiente ≈ 0.86) tra fenoli totali e flavonoidi. Questo è intuitivo, poiché i flavonoidi sono una sottoclasse significativa dei fenoli: un vino con alto contenuto di fenoli totali tende ad avere un alto contenuto di flavonoidi.
Altre correlazioni interessanti includono quella tra intensità del colore e prolina, o tra alcol e prolina (i vini più alcolici spesso mostrano più prolina, forse indice di uve più mature o processi fermentativi differenti).
Invece, variabili come alcalinità delle ceneri risultano inversamente correlate con alcune misure di acidità e colore. Queste relazioni suggeriscono che non tutte le 13 variabili sono indipendenti; potrei considerare tecniche di riduzione dimensionale (come PCA stessa, o analisi delle componenti lineari discriminanti) se volessi semplificare il modello senza perdere troppo informazione. Tuttavia, dato che il numero di feature non è molto elevato (13), posso anche scegliere di mantenerle tutte, gestendo le correlazioni durante la modellazione (ad esempio con modelli meno sensibili alla multicollinearità).
2.3 Distribuzione delle Classi¶
# distribuzione complessiva dei target
class_counts = df_target.value_counts()
class_pct = df_target.value_counts(normalize=True).round(3) * 100
print(class_counts)
print(class_pct)
target 1 71 0 59 2 48 Name: count, dtype: int64 target 1 39.9 0 33.1 2 27.0 Name: proportion, dtype: float64
Questo indica che tutte le classi sono ben rappresentate (anche se la classe 2 ha leggermente meno esempi). Prima di scegliere una tecnica di modellazione, è importante esplorare come si distribuiscono i valori delle variabili per ciascun tipo di vino, al fine di capire quali caratteristiche sono più distintive.
2.4 Distribuzione delle feature per Classe¶
#idx = 0
for feature in df_features.columns:
plt.figure()
for cls in sorted(df["target"].unique()):
subset = df[df["target"] == cls]
plt.hist( subset[feature], bins=50, alpha=0.5, label=f"Classe {cls}")
plt.title(f"Distribuzione di {feature} per Classe")
plt.xlabel(feature)
plt.ylabel("Quantità")
plt.legend()
plt.tight_layout()
# plt.savefig(f"Distribuzione {idx} per Classe")
plt.show()
# idx += 1
Come detto, i 178 vini sono suddivisi in tre classi: 59, 71 e 48 campioni rispettivamente nelle classi 0, 1 e 2 . Questo indica che tutte le classi sono ben rappresentate (anche se la classe 2 ha leggermente meno esempi).
Dall’analisi esplorativa emergono alcune differenze chiave tra le classi: ad esempio, i vini della classe 0 tendono ad avere in media un tasso alcolico più elevato rispetto agli altri, nonché concentrazioni più alte di composti fenolici (fenoli totali e flavonoidi) e un contenuto di prolina nettamente superiore.
Al contrario, i vini della classe 2 spiccano per un’intensità colorante maggiore (colore più intenso) ma mostrano valori inferiori di tonalità (hue più bassa) e contenuto fenolico più basso. La classe 1 presenta valori intermedi in molte misure, fungendo un po’ da categoria intermedia tra le altre due in termini di profilo chimico.
Queste differenze suggeriscono che alcune variabili avranno un forte potere discriminante. Ad esempio, caratteristiche come flavonoidi e prolina potrebbero contribuire molto a distinguere la classe 0, mentre l’intensità del colore potrebbe essere determinante per riconoscere la classe 2.
Un rapido confronto delle medie per classe conferma queste osservazioni:
la classe 0 ha in media ~13.7 di alcol (più alta delle altre), ~2.98 di flavonoidi (molto più alta, contro ~0.78 della classe 2), e ~1115 mg/l 2 di prolina (più del doppio rispetto alla classe 1) – indicatori di un profilo ricco di alcol e composti fenolici.
La classe 2, invece, primeggia in intensità del colore (una media di 7.4 contro 5.5 di classe 0 e 3.1 di classe 1) a fronte di un valore di tonalità inferiore (~0.68 vs ~1.06 delle altre) e mostra il contenuto più basso di flavonoidi e il valore OD280/OD315 più basso (indice di fenoli totali più bassi).
Queste differenze intrinseche nei dati fanno ben sperare circa la separabilità delle classi attraverso tecniche di classificazione.
| Grafico | Messaggio chiave | Spiegazione dettagliata |
| --------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Alcohol | Gradazione alcolica distingue chiaramente Classe 0 | Classe 0 domina l’area 13.5-14.8 % vol; Classe 1 ha la fascia più bassa (11.5-13.2 %), Classe 2 occupa la zona intermedia ma raramente supera 14 %. Un campione ≥ 14 % indica quasi con certezza Classe 0. |
| Malic acid | Acidità malica alta identifica Classe 2 | Classe 2 ha code fino a 5-6 g/L e picco attorno a 3-4 g/L; Classe 1 è confinata < 2.2 g/L (picco 1.3-1.7), Classe 0 si colloca a metà. Valori > 3 g/L sono fortemente indicativi di Classe 2. |
| Ash | Variabile poco discriminante, salvo coda alta di Classe 0 | Tutte le classi si sovrappongono fra 2.2-2.6 g/L; solo Classe 0 mostra frequenze apprezzabili sopra 2.7 g/L. Differenze troppo sottili per essere un driver primario. |
| Alcalinity of ash | Trend crescente 0 < 1 < 2: buona variabile ordinal | Classe 0 picco 15-18, Classe 1 centro 18-22, Classe 2 20-24 con coda fino a 26-28. Fornisce un gradiente netto: un valore > 22 indica verosimilmente Classe 2, < 17 suggerisce Classe 0. |
| Magnesium | Classe 0 più ricca di magnesio (≈100-130 mg/L) | Il picco blu è spostato a destra; Classe 1 è concentrata 85-100 mg/L, Classe 2 si sovrappone ma resta tra 90-115 mg/L. Valori > 115 mg/L segnalano quasi sempre un vino di Classe 0. ||
| Color Intensity | Valori alti ⇒ Classe 2 (vino più “scuro”) | Classe 2 domina area 7–13; Classe 0 occupa 4–7; Classe 1 è confinata 2–5. Un campione > 7 u.c. è quasi certamente Classe 2, mentre < 3 indica verosimilmente Classe 1. |
| Total Phenols | Gradiente 0 > 1 > 2: ottimo driver | Classe 0 picco 2.8–3.3 g/L, Classe 1 2.1–2.6, Classe 2 1.2–2.0. Più fenoli totali → probabilità crescente di Classe 0; valori < 2 indicano Classe 2. |
| Flavanoids | Classe 0 nettamente più ricca; Classe 2 povera | Distribuzione blu centrata 2.8–3.4, arancione 2.0–2.8, verde 0.6–1.4. In un solo colpo distingue 0 da 2; Classe 1 rimane intermedia. |
| Non-flavanoid Phenols | Valori alti segnalano Classe 2 | Classe 2 ha picco 0.45–0.55 g/L; Classe 1 0.30–0.45; Classe 0 0.20–0.30. Soglia empirica: > 0.45 ⇒ quasi sempre Classe 2. |
| Proanthocyanins | Classe 1 concentra i valori medi (1.5–2.2 u.), Classe 2 i più bassi | Classe 1 domina 1.4–2.2 con coda fino 3.6; Classe 0 simile ma leggermente più alta (1.8–2.6); Classe 2 centrifuga su 0.6–1.4. Valori < 1 indicano Classe 2, > 2.5 puntano a Classe 0 o 1 (da combinare con flavanoidi). |
| OD280/OD315 of diluted wines | Scala 0 < 1 < 2: fenoli totali gradualmente decrescenti | Classe 0 concentra i valori alti (≈ 2,9 – 3,7), Classe 1 occupa la fascia intermedia 2,4 – 3,2, Classe 2 è relegata a 1,3 – 2,0. Un campione con indice ≤ 2,1 è quasi sempre Classe 2; ≥ 3 indica in pratica Classe 0. |
| Hue | Hue basso (< 0,8) ⇒ Classe 2; Hue alto (> 1,3) ⇒ Classe 1 | Classe 2 domina la coda bassa (0,55 – 0,80), Classe 0 ruota attorno a 1,05 – 1,20, Classe 1 si estende 0,90 – 1,45 con picco 1,1 – 1,25. Tonalità < 0,9 rafforza la predizione di Classe 2; > 1,3 suggerisce Classe 1, specie se il vino non presenta fenoli elevati. |
| Proline | Marker assoluto della Classe 0 | Distribuzione blu fortemente spostata a destra: 1 000 – 1 650 mg/L. Classi 1 e 2 restano fra 300 – 800 mg/L (picco 400 – 600). Valori > 900 mg/L identificano con altissima probabilità la Classe 0. |
2.5 Outlier?¶
Gli outlier non sono errori di misura: corrispondono a vini con caratteristiche chimiche estreme ma plausibili (es. alcalinità molto alta).
Ho contato gli outlier con la regola di Tukey (± 1,5 × IQR) su ciascuna delle 13 variabili continue: il Wine dataset ne ha pochi e concentrati soprattutto in alcalinity_of_ash, magnesium, color_intensity, malic_acid e ash (fra 3 e 4 campioni ciascuna).
Perchè ho utilizzato questa regola?¶
John Tukey (1977) mostrò che, in una distribuzione normale, il 1,5 × IQR include circa il 99.3 % dei dati: quindi i punti fuori sono davvero rari.
# Identifica outliers in ogni feature usando la regola 1.5 * IQR
outlier_counts = {}
for col in df_features.columns:
Q1 = df_features[col].quantile(0.25) #25% dei dati
Q3 = df_features[col].quantile(0.75) #75% dei dati
IQR = Q3 - Q1 # Larghezza della fascia centrale
lower = Q1 - 1.5 * IQR #lower fence
upper = Q3 + 1.5 * IQR #upper fence
outliers = ((df_features[col] < lower) | (df_features[col] > upper)).sum() # se <lower fence o >upper fence -> outlier
outlier_counts[col] = outliers
# Create a summary DataFrame
outliers_df = pd.DataFrame({
'feature': list(outlier_counts.keys()),
'n_outliers': list(outlier_counts.values())
}).sort_values('n_outliers', ascending=False)
outliers_df
| feature | n_outliers | |
|---|---|---|
| 3 | alcalinity_of_ash | 4 |
| 4 | magnesium | 4 |
| 9 | color_intensity | 4 |
| 1 | malic_acid | 3 |
| 2 | ash | 3 |
| 8 | proanthocyanins | 2 |
| 10 | hue | 1 |
| 0 | alcohol | 0 |
| 5 | total_phenols | 0 |
| 6 | flavanoids | 0 |
| 7 | nonflavanoid_phenols | 0 |
| 11 | od280/od315_of_diluted_wines | 0 |
| 12 | proline | 0 |
Sì, nel Wine dataset ci sono alcuni outlier, ma in numero esiguo e legati a variabili specifiche.
Che impatto potrebbero avere sui modelli?
Tree-based (RandomForest, XGBoost): Sono modelli Robusti: le suddivisioni dipendono da soglie ordinali, quindi pochi punti anomali difficilmente rovinano l’accuratezza.
Lineari / distance-based (Logistic Regression, k-NN, SVM): Sono modelli Più sensibili: quegli outlier possono influenzare le rette di decisione o la distanza media.
3.0 Preparazione e Test dei Modelli Migliori¶
3.1 Scaler si o no?¶
Quale è la differenza di un modello base con o senza l'utilizzo di uno scaler? In seguito spiegherò il perché di questa scelta.
Anzitutto, dividiamo il dataset in train e test.
# Split data
X_train, X_test, y_train, y_test = train_test_split(df_features, df_target, test_size=0.2, random_state=SEED, stratify=df_target)
print(X_train.shape)
print(y_train.shape)
print(X_test.shape)
print(y_test.shape)
(142, 13) (142,) (36, 13) (36,)
Detto ciò, valuto la differenza tra una regressione logistica:
- Senza Scaler
- Con Standard Scaler
- Con Robust Scaler (with_centering=True, with_scaling=True)
In aggiunta a questi 3 test, ne preparo anche uno rimuovendo gli outlier, per valuatare la differenza.
Preparo, quindi, dei dataset senza gli outlier.
# IQR outlier mask
def non_outlier_mask(df: pd.DataFrame, k: float = 1.5) -> pd.Series:
q1 = df.quantile(0.25)
q3 = df.quantile(0.75)
iqr = q3 - q1
lower = q1 - k * iqr
upper = q3 + k * iqr
return ~((df < lower) | (df > upper)).any(axis=1)
mask = non_outlier_mask(df_features)
X_clean = df_features[mask]
y_clean = df_target[mask]
# Split data
X_train_clean, X_test_clean, y_train_clean, y_test_clean = train_test_split(X_clean, y_clean, test_size=0.2, random_state=SEED, stratify=y_clean)
print(X_train_clean.shape)
print(y_train_clean.shape)
print(X_test_clean.shape)
print(y_test_clean.shape)
(128, 13) (128,) (33, 13) (33,)
3.1.1 Confronto tra modelli con o senza Scaler¶
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler, RobustScaler
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.linear_model import LogisticRegression
import numpy as np
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
logreg_raw = LogisticRegression(max_iter=500, random_state=SEED)
logreg_scaled = make_pipeline(StandardScaler(), logreg_raw) # standard scaler
logreg_r_scaled = make_pipeline(RobustScaler(with_centering=True, with_scaling=True), logreg_raw) # robust scaler che scala usando IQR (interquartile range)
logreg_no_outl = make_pipeline(StandardScaler(), logreg_raw) # dataset senza outlier
scores_raw = cross_val_score(logreg_raw, X_train, y_train, cv=cv)
scores_scaled = cross_val_score(logreg_scaled, X_train, y_train, cv=cv)
scores_r_scaled = cross_val_score(logreg_r_scaled, X_train, y_train, cv=cv)
scores_clean = cross_val_score(logreg_no_outl, X_train_clean, y_train_clean, cv=cv)
print(f"Baseline (no scaling): {scores_raw.mean():.3f} ± {scores_raw.std():.3f}")
print(f"With StandardScaler: {scores_scaled.mean():.3f} ± {scores_scaled.std():.3f}")
print(f"With RobustScaler: {scores_r_scaled.mean():.3f} ± {scores_r_scaled.std():.3f}")
print(f"Without Outliers: {scores_clean.mean():.3f} ± {scores_clean.std():.3f}")
Baseline (no scaling): 0.943 ± 0.029 With StandardScaler: 0.965 ± 0.031 With RobustScaler: 0.965 ± 0.031 Without Outliers: 0.968 ± 0.047
| Scenario | Mean acc | ± 1 σ (5 fold) | Considerazioni chiave |
|---|---|---|---|
| Baseline (no scaling) | 0.943 | 0.029 | Solvers lineari penalizzati da feature con scale diverse ⇒ leggero under-fit. |
| StandardScaler | 0.965 | 0.031 | Il centraggio/normalizzazione per σ mette tutte le feature sullo stesso ordine di grandezza, riducendo il bias di regolarizzazione. |
| RobustScaler | 0.965 | 0.031 | Stessa media di StandardScaler: conferma che il dataset è quasi gaussiano e con pochi outlier; i due scaler applicano — di fatto — fattori di scala simili. |
| Without Outliers | 0.968 | 0.047 | Accuracy nominalmente più alta (+0.3 %), ma la deviazione standard aumenta (~+50 %): meno campioni (-17) -> stimatore più instabile. |
Abbiamo alcuni scenari su cui ragionare:
- Anzitutto, la differenza (≈ +2 punti percentuali) tra modelli con o senza Scaler non è casuale: deriva dal fatto che i modelli lineari ottimizzati con gradiente e regolarizzazione, come la Logistic Regression, sono sensibili alla scala delle feature;
- Se rimuovo gli outliers l'Accuracy è nominalmente più alta (+0.3 %), ma la deviazione standard aumenta (~+50 %);
- L’aumento di varianza suggerisce che sto guadagnando per caso su qualche fold e perdendo su altri.
- La rimozione non porta benefici statisticamente significativi e aumenta l’incertezza, quindi ritengo che per questo dataset la complessità non vale il gioco.
In seguito a queste osservazioni, ho deciso che terrò tutti i record del dataset.¶
Testerò nuovamente entrambi gli Scaler con tutti i modelli.¶
3.1.2 Perché usare uno scaler¶
Nel dataset Wine, alcune variabili (es. Proline) hanno range > 1000, altre (es. Flavanoids) < 5.
La Logistic Regression usa, di default, $L_2$‐penalty (parametro C).
Nella Regressione Logistica il termine di regolarizzazione è:
$$ \lambda \sum_{j=1}^{p} w_j^{2} $$
dove $w_j$ è il coefficiente associato alla $j$-esima variabile $x_j$.
- Se $x_j$ varia tra 0 e 5 e $x_k$ tra 0 e 1500 circa, il modello deve assegnare a $w_k$ un valore ≫ $w_j$ per produrre effetti comparabili sul logit.
- L₂ punisce i coefficienti grandi, quindi penalizza di fatto più duramente le feature a piccola varianza e concede più “libertà” a quelle molto scalate, distorcendo l’ottimo.
Con StandardScaler(µ = 0, σ = 1) la penalizzazione diventa equa: ogni feature parte con varianza unitaria e la regolarizzazione “pesa” allo stesso modo su tutti i coefficienti.
3.1.3 Nota bene¶
Per modelli tree-based (RandomForest, XGBoost) l’impatto dello scaling è nullo o trascurabile, quindi lo scaler può restare all’interno della pipeline solo se condiviso con altri modelli oppure essere disattivato selettivamente (ColumnTransformer).
3.2 Test dei modelli Migliori¶
# cross validation
inner_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED) # Inner è per la ricerca del modello
outer_cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=SEED) # Outer è per valutare stima pura
# modelli da testare con Standard Scaler
models_ss = {
"LogisticRegression": make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=1000, multi_class="multinomial", random_state=SEED)
),
"LinearDiscriminant": make_pipeline(
StandardScaler(),
LinearDiscriminantAnalysis()
),
"kNN": make_pipeline(
StandardScaler(),
KNeighborsClassifier(n_neighbors=5)
),
"SVM_RBF": make_pipeline(
StandardScaler(),
SVC(kernel="rbf", C=1, gamma="scale", random_state=SEED)
),
"RandomForest": make_pipeline(
StandardScaler(),
RandomForestClassifier(n_estimators=200, random_state=SEED)
),
"GradientBoosting": make_pipeline(
StandardScaler(),
GradientBoostingClassifier(random_state=SEED)
)
}
# modelli da testare con Robust Scaler
models_rs = {
"LogisticRegression": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
LogisticRegression(max_iter=1000, multi_class="multinomial", random_state=SEED)
),
"LinearDiscriminant": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
LinearDiscriminantAnalysis()
),
"kNN": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
KNeighborsClassifier(n_neighbors=5)
),
"SVM_RBF": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
SVC(kernel="rbf", C=1, gamma="scale", random_state=SEED)
),
"RandomForest": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
RandomForestClassifier(n_estimators=200, random_state=SEED)
),
"GradientBoosting": make_pipeline(
RobustScaler(with_centering=True, with_scaling=True),
GradientBoostingClassifier(random_state=SEED)
)
}
# Standard Scaler
results_ss = []
for name_ss, pipeline_ss in models_ss.items():
scores_ss = cross_val_score(pipeline_ss, X_train, y_train, cv=inner_cv, scoring="accuracy")
results_ss.append({
"Model": name_ss,
"CV Accuracy Mean": scores_ss.mean(),
"CV Accuracy Std": scores_ss.std()
})
df_results_ss = pd.DataFrame(results_ss).sort_values("CV Accuracy Mean", ascending=False)
# Robust Scaler
results_rs = []
for name_rs, pipeline_rs in models_rs.items():
scores_rs = cross_val_score(pipeline_rs, X_train, y_train, cv=inner_cv, scoring="accuracy")
results_rs.append({
"Model": name_rs,
"CV Accuracy Mean": scores_rs.mean(),
"CV Accuracy Std": scores_rs.std()
})
df_results_rs = pd.DataFrame(results_rs).sort_values("CV Accuracy Mean", ascending=False)
print("Benchmark modelli con Standard Scaler (CV su training)")
df_results_ss
Benchmark modelli con Standard Scaler (CV su training)
| Model | CV Accuracy Mean | CV Accuracy Std | |
|---|---|---|---|
| 1 | LinearDiscriminant | 0.978818 | 0.028452 |
| 4 | RandomForest | 0.978818 | 0.017301 |
| 3 | SVM_RBF | 0.978571 | 0.028571 |
| 0 | LogisticRegression | 0.965025 | 0.031404 |
| 2 | kNN | 0.943842 | 0.016716 |
| 5 | GradientBoosting | 0.943350 | 0.028945 |
print("Benchmark modelli con Robust Scaler (CV su training)")
df_results_rs
Benchmark modelli con Robust Scaler (CV su training)
| Model | CV Accuracy Mean | CV Accuracy Std | |
|---|---|---|---|
| 1 | LinearDiscriminant | 0.978818 | 0.028452 |
| 4 | RandomForest | 0.978818 | 0.017301 |
| 3 | SVM_RBF | 0.978571 | 0.028571 |
| 0 | LogisticRegression | 0.965025 | 0.031404 |
| 5 | GradientBoosting | 0.943350 | 0.028945 |
| 2 | kNN | 0.929803 | 0.031146 |
Nota bene!¶
Tutti i modelli mantengono lo stesso punteggio. Tranne uno, il K-NN.
Il K-NN funziona bene quando ogni feature contribuisce alla distanza in modo “equilibrato”. Nel dataset Wine gli outlier sono pochi e la gran parte delle variabili è quasi gaussiana: in questo contesto lo StandardScaler (σ = 1) preserva meglio le proporzioni fra feature, mentre il RobustScaler (IQR = 1) finisce per “schiacciare” quelle distribuzioni e sbilanciare la distanza euclidea (utilizzata nel K-NN), peggiorando quindi l’accuratezza.
3.2.1 Chi è migliore davvero?¶
| Modello | CV mean | CV std | Note rapide |
|---|---|---|---|
| Linear Discriminant | 0,9788 | 0,0285 | Lineare, interpretabile, ipotesi di covarianze simili |
| Random Forest | 0,9788 | 0,0173 | Non lineare, feature importance, maggiore stabilità (std più basso) |
| SVM RBF | 0,9786 | 0,0286 | Non lineare, iperparametri (C, γ), poca interpretabilità |
| Logistic Regression | 0,9650 | 0,0314 | Lineare, semplice |
| k-NN | 0,9438 | 0,0167 | Sensibile alla scala, poco interpretabile |
| Gradient Boosting | 0,9434 | 0,0289 | Buono ma più complesso da tarare |
Accuratezza media: i primi tre modelli sono praticamente a pari merito (la differenza 0,0002 è < deviazione standard).
Stabilità: Random Forest ha la std più bassa, quindi la sua performance varia meno da un fold all’altro.
Interpretabilità: Linear Discriminant vince perché ha coefficienti lineari, facile spiegare perché un vino è classificato in una certa classe (utile per audit di qualità).
best_model_name = df_results_rs.iloc[0]["Model"]
best_pipeline = models_rs[best_model_name]
best_pipeline.fit(X_train, y_train)
y_pred = best_pipeline.predict(X_test)
test_acc = accuracy_score(y_test, y_pred)
print(f"Best model: {best_model_name}")
print(f"Hold-out test accuracy: {test_acc:.3f}")
print("\nClassification report (test set):")
print(classification_report(y_test, y_pred, target_names=[f"Class {i}" for i in np.unique(df_target)]))
Best model: LinearDiscriminant
Hold-out test accuracy: 1.000
Classification report (test set):
precision recall f1-score support
Class 0 1.00 1.00 1.00 12
Class 1 1.00 1.00 1.00 14
Class 2 1.00 1.00 1.00 10
accuracy 1.00 36
macro avg 1.00 1.00 1.00 36
weighted avg 1.00 1.00 1.00 36
Alcune assunzioni:
LDA funziona bene se le classi hanno varianze simili; nel Wine è vero ma andrebbe verificato su nuovi lotti.
Random Forest non fa ipotesi di distribuzione, gestisce bene non-linearità e outlier, fornisce importanza delle feature.
SVM richiede tuning e non mostra chiaramente quali variabili guidano la decisione (black-box).
Andrò a testare LDA e Random Forest.¶
3.3 Linear Discriminant Analysis¶
3.3.1 LDA con differenti Scaler¶
pipe_lda_ss = make_pipeline(StandardScaler(), LinearDiscriminantAnalysis()) # Standard Scaler
pipe_lda_rs = make_pipeline(RobustScaler(with_centering=True, with_scaling=True), LinearDiscriminantAnalysis()) # Robust Scaler
param_grid = [
{'lineardiscriminantanalysis__solver': ['svd'],
'lineardiscriminantanalysis__shrinkage': [None]},
{'lineardiscriminantanalysis__solver': ['svd','lsqr','eigen'],
'lineardiscriminantanalysis__shrinkage': ['auto', None, 0.0, 0.1, 0.2]}
]
grid_ss = GridSearchCV(pipe_lda_ss, param_grid, cv=inner_cv, scoring='accuracy', n_jobs=-1)
grid_ss.fit(X_train, y_train)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('standardscaler', StandardScaler()),
('lineardiscriminantanalysis',
LinearDiscriminantAnalysis())]),
n_jobs=-1,
param_grid=[{'lineardiscriminantanalysis__shrinkage': [None],
'lineardiscriminantanalysis__solver': ['svd']},
{'lineardiscriminantanalysis__shrinkage': ['auto',
None, 0.0,
0.1, 0.2],
'lineardiscriminantanalysis__solver': ['svd', 'lsqr',
'eigen']}],
scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...tAnalysis())]) | |
| param_grid | [{'lineardiscriminantanalysis__shrinkage': [None], 'lineardiscriminantanalysis__solver': ['svd']}, {'lineardiscriminantanalysis__shrinkage': ['auto', None, ...], 'lineardiscriminantanalysis__solver': ['svd', 'lsqr', ...]}] | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| error_score | nan | |
| return_train_score | False |
Parameters
| copy | True | |
| with_mean | True | |
| with_std | True |
Parameters
| solver | 'svd' | |
| shrinkage | None | |
| priors | None | |
| n_components | None | |
| store_covariance | False | |
| tol | 0.0001 | |
| covariance_estimator | None |
best_lda_ss = grid_ss.best_estimator_
print("Best params:", grid_ss.best_params_)
print("Best inner CV:", grid_ss.best_score_)
print("Test acc:", accuracy_score(y_test, best_lda_ss.predict(X_test)))
Best params: {'lineardiscriminantanalysis__shrinkage': None, 'lineardiscriminantanalysis__solver': 'svd'}
Best inner CV: 0.9788177339901478
Test acc: 1.0
grid_rs = GridSearchCV(pipe_lda_rs, param_grid, cv=inner_cv, scoring='accuracy', n_jobs=-1)
grid_rs.fit(X_train, y_train)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('robustscaler', RobustScaler()),
('lineardiscriminantanalysis',
LinearDiscriminantAnalysis())]),
n_jobs=-1,
param_grid=[{'lineardiscriminantanalysis__shrinkage': [None],
'lineardiscriminantanalysis__solver': ['svd']},
{'lineardiscriminantanalysis__shrinkage': ['auto',
None, 0.0,
0.1, 0.2],
'lineardiscriminantanalysis__solver': ['svd', 'lsqr',
'eigen']}],
scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...tAnalysis())]) | |
| param_grid | [{'lineardiscriminantanalysis__shrinkage': [None], 'lineardiscriminantanalysis__solver': ['svd']}, {'lineardiscriminantanalysis__shrinkage': ['auto', None, ...], 'lineardiscriminantanalysis__solver': ['svd', 'lsqr', ...]}] | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| error_score | nan | |
| return_train_score | False |
Parameters
| with_centering | True | |
| with_scaling | True | |
| quantile_range | (25.0, ...) | |
| copy | True | |
| unit_variance | False |
Parameters
| solver | 'svd' | |
| shrinkage | None | |
| priors | None | |
| n_components | None | |
| store_covariance | False | |
| tol | 0.0001 | |
| covariance_estimator | None |
best_lda_rs = grid_rs.best_estimator_
print("Best params:", grid_rs.best_params_)
print("Best inner CV:", grid_rs.best_score_)
print("Test acc:", accuracy_score(y_test, best_lda_rs.predict(X_test)))
Best params: {'lineardiscriminantanalysis__shrinkage': None, 'lineardiscriminantanalysis__solver': 'svd'}
Best inner CV: 0.9788177339901478
Test acc: 1.0
3.3.1 Miglior Modello¶
Il solver 'svd' di sklearn.LDA opera su dati centrati; se i valori sono già nell’ordine di grandezza ≈ 1, cambiare leggermente la scala non altera la stabilità numerica, quindi non compaiono differenze di performance.
Su questo dataset LDA produce lo stesso punteggio con StandardScaler e RobustScaler perché il modello è scale-invariant e le distribuzioni sono quasi gaussiane.
Per questo dataset specifico va bene utilizzare lo StandardScaler; Tuttavia, in presenza di outlier marcati è meglio utilizzare RobustScaler.
best_lda_rs
Pipeline(steps=[('robustscaler', RobustScaler()),
('lineardiscriminantanalysis', LinearDiscriminantAnalysis())])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| steps | [('robustscaler', ...), ('lineardiscriminantanalysis', ...)] | |
| transform_input | None | |
| memory | None | |
| verbose | False |
Parameters
| with_centering | True | |
| with_scaling | True | |
| quantile_range | (25.0, ...) | |
| copy | True | |
| unit_variance | False |
Parameters
| solver | 'svd' | |
| shrinkage | None | |
| priors | None | |
| n_components | None | |
| store_covariance | False | |
| tol | 0.0001 | |
| covariance_estimator | None |
3.3.2 Metriche¶
y_pred = best_lda_rs.predict(X_test)
print(classification_report(y_test, y_pred, target_names=[f"Class {i}" for i in np.unique(df_target)]))
ConfusionMatrixDisplay.from_estimator(best_lda_rs, X_test, y_test)
plt.title(f"Confusion Matrix – {best_model_name} (test set)")
plt.tight_layout()
plt.show()
precision recall f1-score support
Class 0 1.00 1.00 1.00 12
Class 1 1.00 1.00 1.00 14
Class 2 1.00 1.00 1.00 10
accuracy 1.00 36
macro avg 1.00 1.00 1.00 36
weighted avg 1.00 1.00 1.00 36
3.3.3 Influenza dei parametri¶
3.3.3.1 Come calcolare i componenti¶
In LDA il numero massimo di direzioni discriminanti che si possono ottenere è:
$$ \text{max LD} = C - 1 $$
dove $C$ è il numero di classi distinte nel problema.
Questo perché:
- LDA cerca vettori $\mathbf{w}$ che massimizzino il rapporto “varianza-tra-classi / varianza-entro-classe”.
- Lo spazio delle classi ha rango al più $C-1$: infatti i vettori che collegano i centroidi di $C$ classi sono linearmente dipendenti e generano al massimo $C-1$ dimensioni utili a distinguerle.
- Di conseguenza, anche se si hanno a disposizione molte feature originarie ($p=13$ nel Wine), solo $C-1$ combinazioni lineari possono essere realmente informative per la separazione globale.
Nel dataset Wine le etichette sono tre (classe 0, 1, 2), quindi
$$ n\_components_{\text{max}} = 3 - 1 = 2. $$
Scikit-learn, se non si specificano n_components, usa min(p, C-1); quindi imposta automaticamente 2. In output si ottengono due assi:
- LD1 – massimizza la separazione complessiva (cattura >80 % della varianza discriminante);
- LD2 – ortogonale a LD1, spiega la parte residua (fino al 100 %).
Con soli tre gruppi, una terza componente non aggiungerebbe informazione ulteriore perché non esiste un terzo asse indipendente che distingua ulteriormente i centroidi delle classi.
3.3.3.2 Analisi dei Valori¶
# I coefficienti (coef_) di LDA sono disponibili solo con i solver lsqr ed eigen.
# Se usi svd (default), non troverai l’attributo.
# Questa è la soluzione per solver='svd'
# scalings_: colonne = LD1, LD2
lda_svd = best_lda_rs.named_steps["lineardiscriminantanalysis"]
sc = lda_svd.scalings_ # (13 × 2)
abs_weight = np.abs(sc) # modulo
importance = abs_weight / abs_weight.sum(axis=0) # normalizzato per componente
# media del contributo sulle LD
mean_imp = importance.mean(axis=1)
coef_df = (
pd.DataFrame({
"Feature": wine.feature_names,
"LD1_weight": importance[:, 0],
"LD2_weight": importance[:, 1],
"Mean_importance": mean_imp
})
.sort_values("Mean_importance", ascending=False)
)
print(coef_df.head(10).round(3))
Feature LD1_weight LD2_weight Mean_importance 6 flavanoids 0.302 0.122 0.212 12 proline 0.187 0.199 0.193 0 alcohol 0.064 0.163 0.113 9 color_intensity 0.106 0.088 0.097 3 alcalinity_of_ash 0.056 0.093 0.074 11 od280/od315_of_diluted_wines 0.124 0.009 0.067 2 ash 0.002 0.123 0.062 10 hue 0.027 0.083 0.055 5 total_phenols 0.064 0.010 0.037 1 malic_acid 0.023 0.050 0.036
Ecco come rappresentare graficamente le tre classi del dataset Wine nello spazio generato dalle due componenti LDA:
Si adatta una pipeline StandardScaler → LDA.
Con il solver svd si può chiamare .transform() per proiettare tutti i campioni sulle due direzioni discriminanti LD1 e LD2.
Si traccia uno scatter plot LD1 × LD2.
# Project data
X_lda = best_lda_rs.transform(df_features)
plt.figure()
plt.scatter(X_lda[:, 0], X_lda[:, 1], c=df_target)
plt.xlabel("LD1")
plt.ylabel("LD2")
plt.title("Wine dataset: LDA projection (SVD solver)")
plt.legend()
plt.tight_layout()
plt.show()
| Ordine (Mean) | Feature | Peso su LD1 | Peso su LD2 | Cosa indica |
|---|---|---|---|---|
| 1 | Flavanoids | 0,286 | 0,108 | La variabile che più separa le classi 0 vs 2 (alto nei vini classe 0, basso nei classe 2). |
| 2 | Proline | 0,172 | 0,172 | «Firma» del produttore 0; contribuisce sia a LD1 (vs 2) sia a LD2 (vs 1). |
| 3 | Color intensity | 0,133 | 0,104 | Spinta verso la classe 2 (vini più scuri). |
| 4 | Alcohol | 0,061 | 0,145 | Discriminante importante su LD2: classe 1 < classe 0. |
| 5 | Alcalinity of ash | 0,071 | 0,111 | Aiuta a distinguere classe 2 (più alcalina). |
| 6 | Ash | 0,002 | 0,138 | Quasi irrilevante su LD1 ma forte su LD2 (classe 1 vs 2). |
| 7 | OD280/OD315 | 0,110 | 0,008 | Correlato ai flavonoidi; rinforza la separazione 0 vs 2. |
| 8 | Hue | 0,027 | 0,078 | Valori bassi → classe 2, quindi agisce più su LD2. |
| 9 | Malic acid | 0,029 | 0,059 | Acidità alta → classe 2, ma contributo complessivo modesto. |
| 10 | Total phenols | 0,057 | 0,009 | Ridondante con flavonoids; importanza minore. |
LD1 massimizza principalmente l’opposizione Classe 0 (ricca di fenoli) vs Classe 2 (colorata, povera di fenoli).
LD2 affina la distinzione tra Classe 1 (profilo intermedio) e le altre due.
3.3.4 Nuovo test del modello con RepeatedStratifiedKFold¶
scores = cross_val_score(best_lda_rs, X_train, y_train, cv=outer_cv, scoring='accuracy')
print("Repeated CV mean:", scores.mean(), "std:", scores.std())
Repeated CV mean: 0.972512315270936 std: 0.02861326985548244
3.4 Random Forest¶
3.4.1 Test 1¶
# inner_cv è per la ricerca del modello (definito prima!)
# outer_cv è per valutare stima pura (definito prima!)
# Non serve lo scaler!
pipe_rf1 = make_pipeline(
RandomForestClassifier(random_state=SEED)
)
param_dist1 = {
"randomforestclassifier__n_estimators": randint(300, 1000),
"randomforestclassifier__max_depth": [None, 6, 8, 10, 12],
"randomforestclassifier__min_samples_split": [2, 4, 6],
"randomforestclassifier__min_samples_leaf": [1, 2],
"randomforestclassifier__max_features": ["sqrt", "log2", None],
"randomforestclassifier__bootstrap": [True, False]
}
rsearch1 = RandomizedSearchCV(
pipe_rf1, param_distributions=param_dist1, n_iter=50, cv=inner_cv,
scoring="accuracy", random_state=SEED, n_jobs=-1, verbose=0
)
rsearch1.fit(X_train, y_train)
RandomizedSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('randomforestclassifier',
RandomForestClassifier(random_state=0))]),
n_iter=50, n_jobs=-1,
param_distributions={'randomforestclassifier__bootstrap': [True,
False],
'randomforestclassifier__max_depth': [None,
6,
8,
10,
12],
'randomforestclassifier__max_features': ['sqrt',
'log2',
None],
'randomforestclassifier__min_samples_leaf': [1,
2],
'randomforestclassifier__min_samples_split': [2,
4,
6],
'randomforestclassifier__n_estimators': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x0000015EC24360D0>},
random_state=0, scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...om_state=0))]) | |
| param_distributions | {'randomforestclassifier__bootstrap': [True, False], 'randomforestclassifier__max_depth': [None, 6, ...], 'randomforestclassifier__max_features': ['sqrt', 'log2', ...], 'randomforestclassifier__min_samples_leaf': [1, 2], ...} | |
| n_iter | 50 | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| random_state | 0 | |
| error_score | nan | |
| return_train_score | False |
Parameters
| n_estimators | 786 | |
| criterion | 'gini' | |
| max_depth | None | |
| min_samples_split | 6 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'sqrt' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | None | |
| random_state | 0 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
3.4.1.1 Miglior Modello Test 1¶
best_rf_est1 = rsearch1.best_estimator_
print("Best params:", rsearch1.best_params_)
print("Best CV:", rsearch1.best_score_)
print("Test acc:", accuracy_score(y_test, best_rf_est1.predict(X_test)))
Best params: {'randomforestclassifier__bootstrap': True, 'randomforestclassifier__max_depth': None, 'randomforestclassifier__max_features': 'sqrt', 'randomforestclassifier__min_samples_leaf': 1, 'randomforestclassifier__min_samples_split': 6, 'randomforestclassifier__n_estimators': 786}
Best CV: 0.9788177339901478
Test acc: 1.0
| Iper-parametro | Valore scelto | Impatto sul modello | Osservazioni chiave |
|---|---|---|---|
bootstrap |
True | Ogni albero è addestrato su un sotto-campione con rimpiazzo → aumenta la diversità interna dell’ensemble. | Con 13 feature e dataset ridotto, il bootstrap riduce il rischio di sovra-adattamento mantenendo alte prestazioni (CV ≈ 0,979). |
n_estimators |
786 | Numero di alberi. | Quasi 800 alberi garantiscono stabilità statistica; oltre questo valore il miglioramento di CV è marginale. |
max_depth |
None (illimitata) | Permette ad ogni albero di crescere finché non si violano gli altri vincoli. | Insieme a min_samples_split=6 limita la profondità “effettiva”: si crea un equilibrio fra bias (basso) e varianza (contenuta dal bootstrap). |
max_features |
sqrt | A ogni split si testano √p ≈ 3–4 feature casuali. | Mantiene la decorrelazione tra alberi e sfrutta la ridondanza presente (fenoli, indici di colore). |
min_samples_split |
6 | Minimo di campioni richiesti per dividere un nodo. | Impone split solo quando c’è informazione sufficiente, prevenendo rami profondi su pochi vini. |
min_samples_leaf |
1 | Minimo di campioni in una foglia. | Consente foglie pure (utile per classi sbilanciate); il controllo principale rimane su min_samples_split. |
| CV accuracy (5-fold) | 0,979 ± 0,03 | — | Prestazione allineata agli altri modelli top, senza segni di leakage. |
| Accuracy test (hold-out 20 %) | 1,00 | — | Conferma la capacità di generalizzare sui campioni non visti. |
3.4.1.2 Note a riguardo¶
L'esito è molto valido. tuttavia è meglio indagare più a fondo nella scelta degli iperparametri. Nello specifico, andrò a incrementare il range di valori di:
max_depthmin_samples_splitmin_samples_leaf
3.4.2 Test 2: Nuovo test più approfondito¶
# inner_cv è per la ricerca del modello (definito prima!)
# outer_cv è per valutare stima pura (definito prima!)
pipe_rf2 = make_pipeline(
RandomForestClassifier(random_state=SEED)
)
param_dist2 = {
"randomforestclassifier__n_estimators": randint(300, 1500),
"randomforestclassifier__max_depth": [None, 2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20],
"randomforestclassifier__min_samples_split": [2, 4, 6, 8, 10, 12, 14],
"randomforestclassifier__min_samples_leaf": [1, 2, 4, 6, 8],
"randomforestclassifier__max_features": ["sqrt", "log2", None],
"randomforestclassifier__bootstrap": [True, False]
}
rsearch2 = RandomizedSearchCV(
pipe_rf2, param_distributions=param_dist2, n_iter=200, cv=inner_cv,
scoring="accuracy", random_state=SEED, n_jobs=-1, verbose=0
)
rsearch2.fit(X_train, y_train)
RandomizedSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('randomforestclassifier',
RandomForestClassifier(random_state=0))]),
n_iter=200, n_jobs=-1,
param_distributions={'randomforestclassifier__bootstrap': [True,
False],
'randomforestclassifier__max_depth': [None,
2,
3,
4,
6,
8,
10,
12,
14,
16,
18,
20],
'randomforestclassifier__max_features': ['sqrt',
'log2',
None],
'randomforestclassifier__min_samples_leaf': [1,
2,
4,
6,
8],
'randomforestclassifier__min_samples_split': [2,
4,
6,
8,
10,
12,
14],
'randomforestclassifier__n_estimators': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x0000015EC2399C10>},
random_state=0, scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...om_state=0))]) | |
| param_distributions | {'randomforestclassifier__bootstrap': [True, False], 'randomforestclassifier__max_depth': [None, 2, ...], 'randomforestclassifier__max_features': ['sqrt', 'log2', ...], 'randomforestclassifier__min_samples_leaf': [1, 2, ...], ...} | |
| n_iter | 200 | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| random_state | 0 | |
| error_score | nan | |
| return_train_score | False |
Parameters
| n_estimators | 837 | |
| criterion | 'gini' | |
| max_depth | 18 | |
| min_samples_split | 4 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'log2' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | None | |
| random_state | 0 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
3.4.2.1 Miglior Modello Test 2¶
best_rf_est2 = rsearch2.best_estimator_
print("Best params:", rsearch2.best_params_)
print("Best CV:", rsearch2.best_score_)
print("Test acc:", accuracy_score(y_test, best_rf_est2.predict(X_test)))
Best params: {'randomforestclassifier__bootstrap': True, 'randomforestclassifier__max_depth': 18, 'randomforestclassifier__max_features': 'log2', 'randomforestclassifier__min_samples_leaf': 1, 'randomforestclassifier__min_samples_split': 4, 'randomforestclassifier__n_estimators': 837}
Best CV: 0.9788177339901478
Test acc: 1.0
3.4.2.2 Confronto tra i test¶
| Aspetto | TEST 1 | TEST 2
| ------------------------- | ----------------------------------------------------- | ----------------------------------------------------------- |
| Ampiezza search-space | range più stretti (depth: $None, 6–12$; split: $2–6$) | range più ampi (depth: $None, 2–20$; split: $2–14$) |
| n_iter | 50 | 200 |
| Best params | max_depth=None, max_features='sqrt', min_samples_split=6, n_estimators=786 | max_depth=18, max_features='log2', min_samples_split=4, n_estimators=837 |
| Best CV | 0.9788 | 0.9788 |
| Test accuracy | 1.00 | 1.00 |
In seguito al test condotto, emerge che:
- Test 2 esplora più scenari, ma copre anche quello "illimitato" (max_depth = None)
- Nel Test 2 ho effettuato il quadruplo dei campionamenti
- Gli iperparametri sono differenti ma hanno portato al medesimo risultato
- max_depth=None (TEST 1) e max_depth=18 (TEST 2) generano risultati simili sul Wine dataset perché l’insieme di feature informative è relativamente piccolo (13).
Essendo il dataset wine un dataset abbastanza semplice da classificare, è probabile che le metriche vengano "saturate" qualsiasi combinazione venga scelta.
| Parametro | Range TEST 1 | Best TEST 1 | Posizione TEST1* | Range TEST 2 | Best TEST 2 | Posizione TEST 2* |
|---|---|---|---|---|---|---|
n_estimators |
300–1000 | 786 | 63 % | 300–1500 | 837 | 55.8 % |
max_depth |
None/6/8/10/12 | None | border | None/2…20 | 18 | 90 % |
min_samples_split |
2/4/6 | 6 | upper edge | 2…14 | 4 | 28.8 % |
min_samples_leaf |
1/2 | 1 | lower edge | 1/2/4/6/8 | 1 | lower edge |
* Posizione = percentuale nell’intervallo continuo; per liste discrete si guarda se è minimo/massimo.
TEST 1 mostra molti "edge" effect (valori ai bordi dell'intervallo scelto): max_depth=None (senza limite), min_samples_split=6 (limite alto), min_samples_leaf=1 (limite basso).
TEST 2 è più “centrato”, segno che l’intervallo ampliato ha permesso al random search di trovare un ottimo interno.
Voglio, ora, valutare la differenza tra questi modelli e un modello privo di min_samples_leaf = 1.
3.4.3 Test 3: Rimuovo min_samples_leaf = 1¶
# inner_cv è per la ricerca del modello (definito prima!)
# outer_cv è per valutare stima pura (definito prima!)
pipe_rf3 = make_pipeline(
RandomForestClassifier(random_state=SEED)
)
param_dist3 = {
"randomforestclassifier__n_estimators": randint(300, 1500),
"randomforestclassifier__max_depth": [None, 2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20],
"randomforestclassifier__min_samples_split": [2, 4, 6, 8, 10, 12, 14],
"randomforestclassifier__min_samples_leaf": [2, 4, 6, 8],
"randomforestclassifier__max_features": ["sqrt", "log2", None],
"randomforestclassifier__bootstrap": [True, False]
}
rsearch3 = RandomizedSearchCV(
pipe_rf3, param_distributions=param_dist3, n_iter=200, cv=inner_cv,
scoring="accuracy", random_state=SEED, n_jobs=-1, verbose=0
)
rsearch3.fit(X_train, y_train)
RandomizedSearchCV(cv=StratifiedKFold(n_splits=5, random_state=0, shuffle=True),
estimator=Pipeline(steps=[('randomforestclassifier',
RandomForestClassifier(random_state=0))]),
n_iter=200, n_jobs=-1,
param_distributions={'randomforestclassifier__bootstrap': [True,
False],
'randomforestclassifier__max_depth': [None,
2,
3,
4,
6,
8,
10,
12,
14,
16,
18,
20],
'randomforestclassifier__max_features': ['sqrt',
'log2',
None],
'randomforestclassifier__min_samples_leaf': [2,
4,
6,
8],
'randomforestclassifier__min_samples_split': [2,
4,
6,
8,
10,
12,
14],
'randomforestclassifier__n_estimators': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x0000015EC23B1050>},
random_state=0, scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
| estimator | Pipeline(step...om_state=0))]) | |
| param_distributions | {'randomforestclassifier__bootstrap': [True, False], 'randomforestclassifier__max_depth': [None, 2, ...], 'randomforestclassifier__max_features': ['sqrt', 'log2', ...], 'randomforestclassifier__min_samples_leaf': [2, 4, ...], ...} | |
| n_iter | 200 | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| random_state | 0 | |
| error_score | nan | |
| return_train_score | False |
Parameters
| n_estimators | 415 | |
| criterion | 'gini' | |
| max_depth | 16 | |
| min_samples_split | 4 | |
| min_samples_leaf | 2 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'log2' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | False | |
| oob_score | False | |
| n_jobs | None | |
| random_state | 0 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
3.4.3.1 Miglior Modello Test 3¶
best_rf_est_3 = rsearch3.best_estimator_
print("Best params:", rsearch3.best_params_)
print("Best CV:", rsearch3.best_score_)
print("Test acc:", accuracy_score(y_test, best_rf_est_3.predict(X_test)))
Best params: {'randomforestclassifier__bootstrap': False, 'randomforestclassifier__max_depth': 16, 'randomforestclassifier__max_features': 'log2', 'randomforestclassifier__min_samples_leaf': 2, 'randomforestclassifier__min_samples_split': 4, 'randomforestclassifier__n_estimators': 415}
Best CV: 0.9716748768472907
Test acc: 1.0
3.4.3.2 Confronto con i precedenti test e Deduzioni¶
Bias-variance trade-off “ammorbidito” dal bagging
Ogni singolo albero con foglie molto piccole ha varianza alta, ma la Random Forest riduce quella varianza con l’aggregazione (media dei voti). L’aumento di bias che otterrei impostando min_samples_leaf≥2 non viene più compensato dal guadagno sulla varianza, perché la foresta è già stabilizzata (≥ 300 alberi). In sostanza, foglie da 1 mantengono la capacità di separare pattern sottili senza far esplodere l’overfitting a livello di ensemble.
Piccolo numero di campioni + feature molto informative
Wine ha 178 osservazioni / 13 feature; molte variabili (es. Proline, Flavanoids) discriminano fortemente le 3 classi. Con min_samples_leaf=2 o 4 probabilmente rinuncerei a split “decisivi” che isolano pochi campioni ma ben separati; il modello quindi sbaglia proprio quelle istanze nei fold di CV. La foresta vota su alberi diversi: se anche alcuni split basati su 1 solo campione sono rumorosi, verranno “filtrati” dagli altri alberi.
In questo specifico caso prenderò come test più attendibile il Test 2, perché i parametri risultano essere più "centrati".
3.4.4 Metriche del miglior modello¶
from sklearn.metrics import classification_report, ConfusionMatrixDisplay
# prendo il test2!
y_pred = best_rf_est2.predict(X_test)
print(classification_report(y_test, y_pred, target_names=[f"Class {i}" for i in np.unique(df_target)]))
ConfusionMatrixDisplay.from_estimator(best_rf_est2, X_test, y_test)
plt.title("Confusion Matrix – Tuned RF")
plt.tight_layout()
plt.show()
precision recall f1-score support
Class 0 1.00 1.00 1.00 12
Class 1 1.00 1.00 1.00 14
Class 2 1.00 1.00 1.00 10
accuracy 1.00 36
macro avg 1.00 1.00 1.00 36
weighted avg 1.00 1.00 1.00 36
3.4.5 Feature Importance del miglior modello¶
importances = pd.Series(best_rf_est2.named_steps["randomforestclassifier"].feature_importances_ , index=wine.feature_names)
top10 = importances.sort_values(ascending=False).head(10)
plt.figure()
top10.sort_values().plot(kind="barh")
plt.title("Top 10 Feature Importances")
plt.tight_layout()
plt.show()
feat_names = wine.feature_names
top10_idx = np.argsort(importances)[::-1][:10]
print("\nTop-10 features:")
for idx in top10_idx:
print(f"{feat_names[idx]:<35} {importances[idx]:.3f}")
Top-10 features: proline 0.180 flavanoids 0.171 color_intensity 0.170 alcohol 0.130 od280/od315_of_diluted_wines 0.113 hue 0.063 total_phenols 0.051 malic_acid 0.026 magnesium 0.026 alcalinity_of_ash 0.025
| Rank | Feature chimica | Importanza* | Interpretazione enologica |
|---|---|---|---|
| 1 | Proline | 0,180 | Aminoacido legato alla maturazione dell’uva → elevato nei vini di classe 0. |
| 2 | Color intensity | 0,171 | Classe 2 mostra valori decisamente più alti → discriminante visivo primario. |
| 3 | Flavanoids | 0,170 | Comp. fenolici; alto in classe 0, basso in classe 2. |
| 4 | Alcohol | 0,130 | Grado alcolico maggiore nei vini di classe 0. |
| 5 | OD280/OD315 | 0,113 | Indice fenolico che rafforza l’effetto dei flavonoidi. |
| 6 | Hue | 0,063 | Tonalità bassa (≈ 0,6–0,8) caratteristica della classe 2. |
| 7 | Total phenols | 0,051 | Conferma del contenuto fenolico totale. |
| 8 | Malic acid | 0,026 | Acidità più elevata nei vini di classe 2. |
| 9 | Magnesium | 0,026 | Lieve differenza minerale tra i vitigni. |
| 10 | Alcalinity of ash | 0,025 | Contributo marginale, ma utile in combinazione con altri parametri. |
3.4.6 Nuovo test del modello con RepeatedStratifiedKFold¶
# per verificare la varianza post tuning
# outer_cv -> definito prima in fase di preparazione del Random forest
nested_scores = cross_val_score(best_rf_est2, X_train, y_train, cv=outer_cv, scoring="accuracy")
print(nested_scores.mean(), nested_scores.std())
0.9740886699507391 0.02864878266499426
| Punto chiave | Commento personale |
|---|---|
| Coerenza | La media (97,34 %) è sovrapponibile ai ~97–98 % ottenuti con CV semplice ⇒ tuning non ha introdotto overfitting. |
| Varianza | σ ≈ 0,028 → gli score sui 50 fold vanno tipicamente da ~0,92 a ~1,00. Con dataset di soli 142 campioni nel training è fisiologico: basta che una classe sia leggermente sottorappresentata in un fold perché l’accuracy cali. |
| Errore standard | $\text{SEM}= \frac{0{,}0284}{\sqrt{50}} \approx 0{,}004$ ⇒ intervallo di confidenza 95 % sulla media ≈ 97,3 ± 0,8 %. |
| Affidabilità | Al netto della variabilità del campionamento, il modello si aspetta di classificare correttamente tra 96,5 % e 98,1 % dei vini nuovi provenienti dallo stesso dominio. |
| Test set ancora migliore | Il hold-out resta a 100 %. Bene: indica che il fold estratto casualmente era “facile”, ma non serve ri-addestrare. |
La Nested CV conferma che questo modello di Random Forest ottimizzato è stabile e generalizza bene; lo posso portare in produzione con confidenza attorno al 97 % di accuratezza attesa.
4.0 Linear Discriminant Analysis vs Random Forest¶
4.1 Modelli a Confronto¶
| Aspetto | Random Forest (bootstrap =True, n=786, depth=None) | LDA (solver = svd, shrinkage = None) |
|---|---|---|
| Accuratezza media (Nested CV) | 0,979 ± 0,028 | 0,973 ± 0,028 |
| Accuracy test hold-out (36 campioni) | 1,00 | 1,00 |
| Assunzioni | Nessuna ipotesi di distribuzione; gestisce non-linearità e interazioni | Covarianza omogenea fra classi + separazione linearmente combinabile |
| Regolazione iper-parametri | Importante (n_estimators, max_depth, ecc.) – ma stabile | Poco da ottimizzare (solver, shrinkage) |
| Robustezza a outlier / rumore | Alta (ensemble, bagging) | Moderata; outlier possono deformare i coefficienti |
| Interpretabilità | Media: feature importance % (ranking), struttura degli alberi complessa | Alta: coefficienti lineari direttamente leggibili (peso positivo/negativo) |
| Probabilità calibrate | Non calibrate di default; richiede CalibratedClassifierCV |
Produce stime probabilistiche ben calibrate (funzione della distanza lineare) |
| Scalabilità | Training più lento, modello più pesante (786 alberi) | Rapidissimo in training e inferenza |
| Sensibilità a feature ridondanti | Bassa (selezione casuale di feature per split) | Può soffrire di multicollinearità → shrinkage utile |
| Driver chimici principali | Proline, Color intensity, Flavanoids, Alcohol, OD280/OD315 | Idem, ma importanza derivata dai coefficienti |
4.1.1 Quando scegliere LDA¶
| Situazione | Motivo |
|---|---|
| Audit & certificazione (Se devi spiegare al cliente/ente perché il campione è promosso o bocciato) | Coefficienti lineari mostrano subito l’influenza di ogni parametro. |
| Dataset piccolo ma “pulito” e quasi lineare | LDA sfrutta bene la separabilità intrinseca senza rischiare di over-fittare. |
| Performance OK sopra il 95 % e serve un modello leggero da eseguire su sistemi embedded o in laboratorio offline | LDA è minimale (poche operazioni matriciali). |
# salvataggio del modello
# joblib.dump(best_lda_rs, "wine_lda_model.joblib", compress=("xz", 3))
4.1.2 Quando scegliere Random Forest¶
| Situazione | Motivo |
|---|---|
| Robustezza a futuri lotti con valori chimici leggermente fuori range (nuovi vitigni, annate diverse) | RF cattura relazioni non lineari e gestisce outlier senza forte degrado. |
| Dataset destinato a crescere o con potenziali drift | L’ ensemble continua a performare bene con dati più rumorosi; feature importance aiuta a rilevare nuove variabili decisive. |
| **Vuoi un *second opinion*** automatico a fianco di LDA | Utilizzo RF come “guardiano”: se LDA e RF concordano → certificazione, se discordano → revisione umana. |
# salvataggio del modello
# joblib.dump(best_rf_est2, "wine_rf_model.joblib", compress=("xz", 3))
5.0 Conclusioni¶
Ti ringrazio per aver letto il mio notebook.
Visita il mio blog personale o colleghiamoci su:
Stefano Nocco
Librerie Utilizzate
numpy==2.1.0
pandas==2.2.0
scipy==1.13.0
scikit-learn==1.7.0
matplotlib==3.9.0
seaborn==0.14.0
joblib==1.4.0
Cosa Possiamo Imparare?
Il notebook (Python 3.11, Pandas 2.x, Scikit-learn 1.7) dimostra competenze complete in data wrangling, visualizzazione, modellazione, tuning e comunicazione dei risultati. È pensato per essere riproducibile end-to-end: Puoi andare sul mio profilo di Kaggle e scaricare il notebook.
Fonti
Se ti incuriosisce vedere le classifiche, scoprire quali vitigni sono in ascesa, o magari vuoi cimentarti tu stesso in un’analisi dei dati, dai un’occhiata al progetto su Kaggle.
All’interno del progetto troverai ulteriori fonti.
Il bello del vino è che, anche quando ci sono i numeri di mezzo, rimane sempre una questione di passione, emozione e scoperta.
Alla prossima degustazione, che sia guidata dal cuore… o da qualche dato in più!
