Questo modulo segna il passaggio dalla teoria alla pratica intensiva. Ci immergeremo nell'applicazione concreta delle tecniche di Data Science e Machine Learning apprese finora. Copriremo l'intero flusso di lavoro, partendo dall'analisi esplorativa e dalla preparazione avanzata dei dati, passando per la visualizzazione efficace, fino alla realizzazione di un progetto end-to-end che integri tutte queste competenze. L'obiettivo è renderti autonomo/a nell'affrontare un problema di data science nel mondo reale.
Prima di poter costruire qualsiasi modello, dobbiamo conoscere a fondo i nostri dati. Questa fase è cruciale per scoprire pattern, identificare problemi e formulare ipotesi. È un lavoro investigativo che pone le basi per tutto il lavoro successivo. Non sottovalutare questa fase: dati di scarsa qualità portano inevitabilmente a modelli di scarsa qualità (Garbage In, Garbage Out).
Approfondimento: L'Arte dell'EDA e della Pulizia
- Exploratory Data Analysis (EDA): Il Detective dei Dati
- Obiettivo Primario: Comprendere a fondo il dataset prima di pensare ai modelli. Significa capire:
- Quali variabili (colonne/feature) abbiamo? Di che tipo sono (numeriche continue/discrete, categoriche nominali/ordinali, testuali, date/time)?
- Come sono distribuite le singole variabili? (Forma della distribuzione, tendenza centrale - media/mediana, dispersione - varianza/dev.std/IQR)
- Quali relazioni esistono tra le variabili? (Correlazioni lineari/non lineari, dipendenze, interazioni)
- Ci sono problemi evidenti nei dati? (Valori mancanti, outlier estremi che potrebbero essere errori, formati inconsistenti, righe duplicate)
- Approccio: È un processo iterativo e investigativo, non una checklist rigida. Si fanno domande ai dati ("Qual è la distribuzione dell'età dei clienti?", "Il reddito è correlato al punteggio?"), si visualizza per cercare risposte, e le risposte spesso generano nuove domande più specifiche.
- Tecniche Fondamentali:
- Ispezione Iniziale:
df.info()(fondamentale per tipi di dati e non-null counts),df.shape(dimensioni),df.head() / df.tail()(ispezione visiva rapida),df.columns(verifica nomi colonne).- Statistiche Descrittive:
df.describe()(per numeriche: count, mean, std, min, max, quartili 25-50-75%),df.describe(include='object')odf.describe(include='category')(per categoriche: count, unique - n° categorie, top - categoria più frequente, freq - frequenza della top),df['colonna_cat'].value_counts()(frequenze per ogni categoria),df.corr()(matrice di correlazione tra numeriche, utile per vedere relazioni lineari).- Visualizzazioni Univariate (una variabile alla volta):
- Istogrammi (
sns.histplot) e Density plot (sns.kdeplot): Essenziali per capire la forma della distribuzione delle variabili numeriche (simmetrica, asimmetrica a destra/sinistra, bimodale?).- Bar chart (
sns.countplot): Il modo migliore per visualizzare le frequenze (conteggi) delle diverse categorie in una variabile categorica.- Box Plot (
sns.boxplot): Ottimo per avere una sintesi rapida di una variabile numerica: mostra mediana (Q2), quartili (Q1, Q3), range interquartile (IQR = Q3-Q1), e identifica potenziali outlier come punti al di fuori diQ1 - 1.5*IQReQ3 + 1.5*IQR.- Visualizzazioni Bivariate/Multivariate (relazioni tra due o più variabili):
- Scatter plot (
sns.scatterplot): Standard per esplorare la relazione tra due variabili numeriche. Si può aggiungere una terza (o quarta) variabile categorica usando il colore (hue) o la dimensione (size) dei punti.- Pair plot (
sns.pairplot): Crea una matrice di scatter plot per tutte le coppie di variabili numeriche nel dataset, con istogrammi o density plot sulla diagonale. Utile per una visione d'insieme rapida, ma diventa illeggibile con troppe variabili.- Heatmap (
sns.heatmap): Perfetta per visualizzare matrici, specialmente la matrice di correlazione (df.corr()). I colori aiutano a identificare rapidamente correlazioni forti (positive o negative).- Box plot / Violin plot raggruppati (
sns.boxplot(x='categoria', y='numerica', ...)): Fondamentali per confrontare la distribuzione di una variabile numerica tra diverse categorie (es. confrontare il reddito tra uomini e donne). I Violin plot (sns.violinplot) aggiungono la forma della distribuzione al box plot.- Pulizia Dati (Data Cleaning): Mettere Ordine nel Caos
- Obiettivo: Identificare, diagnosticare e correggere (o rimuovere strategicamente) errori, incongruenze e dati mancanti per garantire che i dati utilizzati per l'analisi e la modellazione siano il più possibile accurati, consistenti e affidabili.
- Gestione Valori Mancanti (Missing Values - NaN):
- Identificazione:
df.isnull().sum()per contare i NaN per colonna,sns.heatmap(df.isnull(), cbar=False)per una visualizzazione della loro distribuzione.- Strategie di Gestione:
- Rimozione:
- Righe (
df.dropna(subset=[col])): Se i NaN sono pochi rispetto al totale delle righe e distribuiti casualmente, rimuovere le righe con NaN in colonne importanti può essere una soluzione semplice. Attenzione a non eliminare troppe informazioni.- Colonne (
df.drop('nome_colonna', axis=1)): Se una colonna ha una percentuale molto alta di valori mancanti (es. > 50-70%), potrebbe non essere informativa e la sua rimozione è spesso giustificata.- Imputazione (Sostituzione): Riempire i NaN con valori stimati. È spesso preferibile alla rimozione per non perdere dati.
- Semplice: Media (sensibile agli outlier), Mediana (robusta agli outlier, buona scelta per distribuzioni asimmetriche), Moda (valore più frequente, unica scelta sensata per categoriche).
SimpleImputerdi Scikit-learn automatizza questo.- Avanzata: Regression Imputation (prevedere il valore mancante usando le altre feature come predittori in un modello di regressione), K-Nearest Neighbors Imputation (
KNNImputer, imputa basandosi sulla media/mediana dei K vicini più simili), metodi multivariati (es. MICE - Multiple Imputation by Chained Equations). La scelta dipende dalla natura dei dati (MCAR, MAR, MNAR) e dalla complessità richiesta.- Gestione Duplicati:
- Identificazione:
df.duplicated().sum()per contare le righe esattamente identiche.- Rimozione:
df.drop_duplicates()è la soluzione standard. Attenzione a considerare subset di colonne se il duplicato è definito solo da alcune di esse.- Gestione Outlier (Valori Anomali):
- Identificazione: Box plot, Scatter plot, Z-score (
(x - media) / std_dev, valori con |Z| > 3 sono spesso considerati outlier), IQR score (valori fuori da[Q1 - 1.5*IQR, Q3 + 1.5*IQR]).- Trattamento (Decisione Critica):
- Sono errori? (es. età 200 anni) -> Correggere se possibile, altrimenti rimuovere la riga o trattare come NaN.
- Sono valori estremi ma reali? (es. reddito di un miliardario) -> La decisione dipende dall'obiettivo e dall'algoritmo ML:
- Rimozione: Semplice ma potenzialmente pericoloso, si perde informazione su eventi rari ma importanti.
- Trattamento/Capping (Winsorizing): Sostituire gli outlier con il valore limite più vicino (es. 99° percentile o Q3 + 1.5*IQR). Conserva la riga ma riduce l'impatto dell'estremo.
- Trasformazione: Applicare trasformazioni come log, radice quadrata può "comprimere" la coda lunga della distribuzione riducendo l'influenza degli outlier.
- Usare Algoritmi Robusti: Alcuni modelli (es. alberi decisionali, Random Forest) sono intrinsecamente meno sensibili agli outlier rispetto ad altri (es. Regressione Lineare, SVM, KNN). A volte, non fare nulla è la scelta migliore se si usa un modello robusto.
- Correzione Errori / Inconsistenze:
- Formati: Standardizzare date (es. a
YYYY-MM-DD), formattare numeri (es. rimuovere simboli valuta, virgole).- Categorie Inconsistenti: Correggere typo o diverse grafie per la stessa categoria (es. 'M', 'Maschio', 'male' -> 'M'). Usare metodi stringa (
.str.lower(),.str.replace(),.str.strip()).- Valori Impossibili: Identificare e correggere valori che violano regole logiche (es. data ordine successiva a data consegna).
🐍 Codice Dimostrativo (EDA e Pulizia con Pandas/Seaborn/Sklearn)
Python
import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from sklearn.impute import SimpleImputer # Creare un dataset di esempio più realistico data = { 'Eta': [25, 30, np.nan, 45, 22, 30, 50, 35, 45, 22], 'Genere': ['M', 'F', 'M', 'F', 'F', 'M', np.nan, 'F', 'M', 'F'], # Reddito con un outlier evidente (200000) 'Reddito': [50000, 60000, 55000, 75000, 48000, 62000, 200000, 70000, 78000, 50000], 'Punteggio': [3.5, 4.0, 3.8, 4.5, 3.2, 4.1, 4.8, 3.9, 4.6, 3.5] } df = pd.DataFrame(data) # Aggiungere una riga duplicata intenzionalmente df = pd.concat([df, df.iloc[0:1]], ignore_index=True) print("--- Dataset Iniziale ---") print(df) print("\\n--- Info Iniziali e Conteggio Missing Values ---") df.info() # Tipi di dato e non-null counts print("\\nValori Mancanti per Colonna:") print(df.isnull().sum()) # Conteggio NaN per colonna print("\\nNumero Righe Duplicate:") print(df.duplicated().sum()) # Conteggio righe duplicate # --- Pulizia Dati --- print("\\n--- Inizio Pulizia ---") # 1. Rimuovi duplicati (mantenendo la prima occorrenza) df_cleaned = df.drop_duplicates().copy() # Usare .copy() per evitare SettingWithCopyWarning print(f"Shape dopo drop_duplicates: {df_cleaned.shape}") # 2. Imputa 'Eta' con la mediana (più robusta all'outlier nel Reddito rispetto alla media) median_imputer_eta = SimpleImputer(strategy='median') # fit_transform richiede input 2D, anche per una sola colonna -> df_cleaned[['Eta']] # Il risultato è un array NumPy, lo riassegnamo alla colonna df_cleaned['Eta'] = median_imputer_eta.fit_transform(df_cleaned[['Eta']]) print("Valore mediano usato per imputare Eta:", median_imputer_eta.statistics_[0]) # 3. Imputa 'Genere' con la moda (valore più frequente) mode_imputer_genere = SimpleImputer(strategy='most_frequent') df_cleaned['Genere'] = mode_imputer_genere.fit_transform(df_cleaned[['Genere']]) print("Valore moda usato per imputare Genere:", mode_imputer_genere.statistics_[0]) print("\\n--- Dopo Pulizia e Imputazione Semplice ---") print("Valori Mancanti Ora:") print(df_cleaned.isnull().sum()) # Dovrebbe essere zero print("Prime righe del df pulito e imputato:") print(df_cleaned.head()) # 4. Identificazione Outlier nel Reddito usando il metodo IQR print("\\n--- Identificazione Outlier (Reddito con IQR) ---") Q1 = df_cleaned['Reddito'].quantile(0.25) Q3 = df_cleaned['Reddito'].quantile(0.75) IQR = Q3 - Q1 limite_inf = Q1 - 1.5 * IQR limite_sup = Q3 + 1.5 * IQR print(f"Q1 Reddito: {Q1}, Q3 Reddito: {Q3}, IQR: {IQR}") print(f"Limiti per outlier: Inferiore={limite_inf}, Superiore={limite_sup}") outliers_reddito = df_cleaned[(df_cleaned['Reddito'] < limite_inf) | (df_cleaned['Reddito'] > limite_sup)] print(f"Outlier Reddito Identificati ({len(outliers_reddito)}):") print(outliers_reddito) # Qui potremmo decidere come trattare l'outlier. # Esempio: rimuoverlo per analisi successive focalizzate sulla maggioranza dei dati. df_no_outlier = df_cleaned[~((df_cleaned['Reddito'] < limite_inf) | (df_cleaned['Reddito'] > limite_sup))] print(f"\\nShape dopo rimozione outlier Reddito: {df_no_outlier.shape}") # --- EDA Visuale sul df_cleaned (prima della rimozione outlier per vederlo) --- print("\\n--- EDA Visuale (sul df con outlier per confronto) ---") # Impostazioni globali per i plot sns.set_theme(style="whitegrid") plt.figure(figsize=(16, 12)) # Aumenta dimensione figura plt.suptitle("Analisi Esplorativa Visuale del Dataset Pulito", fontsize=16, y=1.02) # Istogramma e KDE per Età plt.subplot(2, 3, 1) sns.histplot(data=df_cleaned, x='Eta', kde=True, bins=5) # bins=5 per pochi dati plt.title('Distribuzione Età (Dopo Imputazione)') # Countplot per Genere plt.subplot(2, 3, 2) sns.countplot(data=df_cleaned, x='Genere', palette='viridis') plt.title('Distribuzione Genere (Dopo Imputazione)') # Boxplot Reddito (mostra chiaramente l'outlier) plt.subplot(2, 3, 3) sns.boxplot(data=df_cleaned, y='Reddito', palette='magma') plt.title('Box Plot Reddito (con Outlier)') # Scatter plot Età vs Reddito, colorato per Genere plt.subplot(2, 3, 4) sns.scatterplot(data=df_cleaned, x='Eta', y='Reddito', hue='Genere', s=100, alpha=0.7) # s aumenta dimensione punti plt.title('Età vs Reddito per Genere') # Boxplot Punteggio per Genere (confronto distribuzioni) plt.subplot(2, 3, 5) sns.boxplot(data=df_cleaned, x='Genere', y='Punteggio', palette='viridis') plt.title('Distribuzione Punteggio per Genere') # Matrice di correlazione (solo variabili numeriche) plt.subplot(2, 3, 6) corr_matrix = df_cleaned[['Eta', 'Reddito', 'Punteggio']].corr() sns.heatmap(corr_matrix, annot=True, cmap='coolwarm', fmt=".2f", linewidths=.5) plt.title('Matrice di Correlazione Numerica') plt.tight_layout(rect=[0, 0, 1, 0.98]) # Aggiusta layout per titolo principale plt.show() # Pairplot per una visione d'insieme delle relazioni a coppie (sul df senza outlier) print("\\n--- Pairplot (sul df senza outlier Reddito) ---") sns.pairplot(df_no_outlier, hue='Genere', palette='viridis', diag_kind='kde') plt.suptitle('Pair Plot delle Features (Senza Outlier Reddito)', y=1.02) plt.show()
💪 Esercizi Pratici
- Titanic EDA: Carica il dataset Titanic (
seaborn.load_dataset('titanic')). Esegui un'analisi EDA completa:
- Controlla tipi di dati e valori mancanti (
info(),isnull().sum()).- Calcola statistiche descrittive (
describe(include='all')).- Crea almeno 3 visualizzazioni significative:
- Distribuzione dell'età (
Age) usandosns.histplot.- Tasso di sopravvivenza (
Survived) per classe (Pclass) usandosns.countplotosns.barplot.- Relazione tra età (
Age) e prezzo biglietto (Fare), colorando per sopravvivenza (Survived) usandosns.scatterplot.- Documenta brevemente le tue osservazioni principali in celle Markdown.
- Titanic Outlier Detection (Fare): Sul dataset Titanic, identifica gli outlier nella colonna
Fareusando il metodo Z-score. Considera outlier i punti con|Z-score| > 3.
- Calcola lo Z-score per ogni valore di
Fare.- Filtra il DataFrame per trovare le righe considerate outlier. Quante ne trovi?
- Discuti brevemente: in base al contesto del Titanic, questi alti valori di
Faresono probabilmente errori o rappresentano biglietti reali molto costosi (es. suite)? Come influirebbe questa considerazione sulla decisione di rimuoverli o trattarli?- Titanic Imputazione Avanzata (Age): Imputa i valori mancanti nella colonna
Agedel Titanic usandoKNNImputerdasklearn.impute.
- Usa
n_neighbors=5(valore comune di default).- Considera le feature
PclasseFarecome quelle su cui basare la similarità per l'imputazione (dovrai prima fare OHE su Pclass e magari imputare/scalare Fare se necessario per KNNImputer).- Crea un istogramma della distribuzione dell'età dopo l'imputazione con KNNImputer.
- Confrontala visivamente con la distribuzione che otterreesti imputando
Agesemplicemente con la mediana. Quale approccio ti sembra catturare meglio la variabilità originale (escludendo i NaN)?
💡 Miglioramenti Didattici e Spunti di Riflessione
- 📊 Visualizzazione Guida:
- Creare un "catalogo" visuale dei grafici EDA comuni (istogramma, boxplot, scatter, bar, heatmap, violin) spiegando con una frase chiave cosa ciascuno rivela sui dati (es. Istogramma -> Forma distribuzione; Boxplot -> Sintesi e outlier; Scatter -> Relazione tra due numeriche).
- Sviluppare un flowchart decisionale semplice per la gestione dei valori mancanti:
- Pochi NaN in una colonna non cruciale? -> Considera rimozione righe.
- Colonna con >70% NaN? -> Considera rimozione colonna.
- NaN in colonna categorica? -> Imputa con Moda.
- NaN in colonna numerica? -> Mediana (default sicuro), Media (se simmetrica e senza outlier), KNNImputer/Regression (se vuoi più precisione e hai altre feature correlate).
- 🏢 Caso d'Uso Reale:
- Pulizia Dati da Sensori IoT: Immagina dati da sensori di temperatura. EDA per visualizzare trend giornalieri/settimanali. Pulizia per gestire: letture mancanti dovute a problemi di trasmissione (imputazione basata sul tempo o su sensori vicini?), identificazione e filtraggio/capping di letture anomale (outlier) dovute a picchi di tensione o malfunzionamenti temporanei del sensore (usando IQR o Z-score su finestre temporali).
- EDA su Log di Navigazione Web: Analizzare i log di un sito e-commerce. EDA per visualizzare: pagine più visitate (bar chart), percorsi utente comuni (grafici Sankey?), tempo medio speso per pagina (istogramma/boxplot). Pulizia per gestire: sessioni incomplete, identificare bot, standardizzare user agent.
- ❓ Domanda Aperta (Stimolo Critico):
Una volta che i dati sono puliti, possiamo iniziare a modellarli e trasformarli per renderli più "digeribili" e informativi per i nostri algoritmi di Machine Learning. Questa fase, spesso chiamata Feature Engineering, è dove la conoscenza del dominio e la creatività possono fare una grande differenza. Successivamente, selezioniamo le feature più promettenti.
Approfondimento: Creare e Selezionare le Feature Migliori
import pandas as pd
import numpy as np
from sklearn.preprocessing import LabelEncoder, OneHotEncoder, StandardScaler, MinMaxScaler, PolynomialFeatures, RobustScaler
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn import set_config
from sklearn.linear_model import LogisticRegression
# Dataset di esempio
df = pd.DataFrame({
'Eta': [25, 45, 30, 50, 35],
'Reddito': [50000, 80000, 65000, 95000, 200000], # outlier
'Categoria': ['A', 'B', 'A', 'C', 'B'],
'Priorita': ['Media', 'Alta', 'Bassa', 'Media', 'Alta']
})
# Variabile target
y_target = np.array([0, 1, 0, 1, 1])
print("--- Dataset Originale ---")
print(df)
# Label Encoding per 'Priorita'
priorita_map = {'Bassa': 0, 'Media': 1, 'Alta': 2}
df['Priorita'] = df['Priorita'].map(priorita_map)
print("\\nDataFrame con Priorita Mappata:")
print(df)
# Definizione colonne
numeric_features = ['Eta', 'Reddito', 'Priorita']
categorical_features = ['Categoria']
# Pipeline per numeriche
numeric_transformer = Pipeline(steps=[
('scaler', RobustScaler())
])
# Pipeline per categoriche
categorical_transformer = Pipeline(steps=[
('onehot', OneHotEncoder(handle_unknown='ignore', drop='first'))
])
# ColumnTransformer
preprocessor = ColumnTransformer(
transformers=[
('num', numeric_transformer, numeric_features),
('cat', categorical_transformer, categorical_features)
],
remainder='passthrough'
)
# Applichiamo solo il preprocessing
print("\\n--- Output del solo Preprocessor ---")
X_processed = preprocessor.fit_transform(df)
try:
feature_names_out = preprocessor.get_feature_names_out()
df_processed = pd.DataFrame(X_processed, columns=feature_names_out)
print("DataFrame dopo Preprocessing:")
print(df_processed)
except Exception as e:
print(f"Errore nel recuperare i nomi delle feature: {e}")
print("Output del preprocessor come array NumPy:")
print(X_processed)
# Feature selection
print("\\n--- Feature Selection con SelectKBest ---")
selector = SelectKBest(score_func=f_classif, k=3)
X_selected = selector.fit_transform(X_processed, y_target)
selected_indices = selector.get_support(indices=True)
try:
selected_features = [feature_names_out[i] for i in selected_indices]
df_selected = pd.DataFrame(X_selected, columns=selected_features)
print("Feature selezionate:")
print(df_selected)
except NameError:
print(f"Indici selezionati: {selected_indices}")
print(X_selected)
# Pipeline completa
print("\\n--- Pipeline Completa con Classificatore ---")
full_pipeline_with_selection = Pipeline(steps=[
('preprocessing', preprocessor),
('selection', SelectKBest(score_func=f_classif, k=3)),
('classifier', LogisticRegression(random_state=42))
])
set_config(display='diagram')
print("Pipeline definita.")
# display(full_pipeline_with_selection) # Solo in Jupyter
💪 Esercizi Pratici
💡 Miglioramenti Didattici e Spunti di Riflessione