🤖 Scikit-learn · Intermédiaire

Prédiction de prix immobiliers avec Scikit-learn

⏱ 45 minutes🐍 Python 3.11🤖 Scikit-learn 1.4🐼 Pandas

Ce tutoriel construit un pipeline ML complet pour prédire les prix de l'immobilier : exploration des données, preprocessing, feature engineering, entraînement de plusieurs modèles, validation croisée et sélection du meilleur modèle.

1. Données et exploration

1_exploration.py
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing

# ── Charger le dataset California Housing ──
housing = fetch_california_housing(as_frame=True)
df = housing.frame

print(df.shape)       # (20640, 9)
print(df.info())
print(df.describe())

# Colonnes :
# MedInc      — revenu médian du quartier
# HouseAge    — âge médian des maisons
# AveRooms    — nombre moyen de pièces
# AveBedrms   — nombre moyen de chambres
# Population  — population du quartier
# AveOccup    — occupation moyenne
# Latitude, Longitude
# MedHouseVal — PRIX CIBLE (en centaines de milliers $)

# ── Analyser la variable cible ──
print(f"Prix moyen : ${df['MedHouseVal'].mean() * 100_000:,.0f}")
print(f"Prix médian : ${df['MedHouseVal'].median() * 100_000:,.0f}")

# ── Détecter les valeurs aberrantes ──
Q1 = df['MedHouseVal'].quantile(0.25)
Q3 = df['MedHouseVal'].quantile(0.75)
IQR = Q3 - Q1
outliers = df[(df['MedHouseVal'] < Q1 - 1.5*IQR) | (df['MedHouseVal'] > Q3 + 1.5*IQR)]
print(f"Outliers : {len(outliers)} ({len(outliers)/len(df)*100:.1f}%)")

# ── Corrélations avec la cible ──
correlations = df.corr()['MedHouseVal'].sort_values(ascending=False)
print("\nCorrélations avec le prix :")
print(correlations)
Ce bloc charge le dataset California Housing de 20 640 maisons avec 8 features. .info() montre les types et les valeurs manquantes, .describe() donne des statistiques descriptives (moyenne, écart-type, quartiles). La détection des outliers utilise l'IQR (Interquartile Range) : les valeurs aberrantes sont celles qui se situent au-delà de 1.5 × IQR au-dessus du Q3 ou en-dessous du Q1. Les corrélations de Pearson identifient les features les plus fortement liées au prix cible.

2. Preprocessing et feature engineering

📖 Terme : Pipeline scikit-learn

Définition : Un Pipeline est une chaîne de transformations et d'un modèle final exécutés dans l'ordre. Chaque étape transforme les données pour la suivante, jusqu'au modèle qui fait les prédictions.

But : Garantir que le même preprocessing est appliqué automatiquement à l'entraînement et à la prédiction, et éviter la fuite de données (data leakage).

Pourquoi ici : Sans Pipeline, c'est facile d'oublier de normaliser les données de test, ce qui mène à des erreurs de 10-100x sur les prédictions. Un Pipeline élimine cette source d'erreur courante en encapsulant le preprocessing.

La prévention de data leakage via Pipeline est critique : si on apprend les statistiques de normalisation (moyenne, écart-type) sur TOUS les données (train + test), puis on divise, le modèle "voit" indirectement le test set pendant le preprocessing. Avec un Pipeline, les stats ne voient que X_train.
2_preprocessing.py
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer

housing = fetch_california_housing(as_frame=True)
df = housing.frame

# ── Feature Engineering ──
df['rooms_per_household'] = df['AveRooms'] / df['AveOccup']
df['bedrooms_ratio'] = df['AveBedrms'] / df['AveRooms']
df['population_per_household'] = df['Population'] / df['AveOccup']
df['income_per_room'] = df['MedInc'] / df['AveRooms']

# Log-transform pour les distributions skewed
df['log_population'] = np.log1p(df['Population'])

# ── Séparation features/target ──
X = df.drop('MedHouseVal', axis=1)
y = df['MedHouseVal']

# ── Split train/test (80/20) ──
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(f"Train : {len(X_train)} | Test : {len(X_test)}")

# ── Pipeline de preprocessing ──
numeric_features = X.columns.tolist()
numeric_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),  # Gérer les NaN
    ('scaler', StandardScaler())                    # Standardiser
])

preprocessor = ColumnTransformer([
    ('num', numeric_transformer, numeric_features)
])
Ce bloc construit un Pipeline de preprocessing en deux étapes : (1) l'imputation des valeurs manquantes avec la médiane (stratégie robuste), (2) la normalisation (StandardScaler, qui centre sur 0 et réduit à variance 1). Le Pipeline assure que ces transformations sont apprises sur X_train uniquement (la médiane et la moyenne/std sont calculées sur train) et appliquées identiquement à X_test — cela élimine la fuite de données majeure.

3. Entraîner et comparer les modèles

3_modeles.py
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.svm import SVR
from sklearn.model_selection import cross_val_score
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.pipeline import Pipeline
import numpy as np

# ── Définir les modèles à comparer ──
models = {
    'Linear Regression': LinearRegression(),
    'Ridge': Ridge(alpha=1.0),
    'Lasso': Lasso(alpha=0.1),
    'Random Forest': RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1),
    'Gradient Boosting': GradientBoostingRegressor(n_estimators=100, random_state=42),
}

results = {}

for name, model in models.items():
    # Pipeline = preprocessor + modèle
    pipe = Pipeline([
        ('preprocessor', preprocessor),
        ('model', model)
    ])

    # Validation croisée 5-fold : split en 5 folds, entraîne 5 fois, évalue sur chaque fold
    cv_scores = cross_val_score(
        pipe, X_train, y_train,
        cv=5,
        scoring='neg_root_mean_squared_error',
        n_jobs=-1
    )
    rmse_cv = -cv_scores.mean()
    # Les 5 scores sont calculés sur 5 folds différents, moyennés pour robustesse

    # Entraîner sur le train set complet et évaluer sur le test set
    pipe.fit(X_train, y_train)
    y_pred = pipe.predict(X_test)

    rmse = mean_squared_error(y_test, y_pred, squared=False)
    mae = mean_absolute_error(y_test, y_pred)
    r2 = r2_score(y_test, y_pred)

    results[name] = {
        'CV RMSE': round(rmse_cv, 4),
        'Test RMSE': round(rmse, 4),
        'Test MAE': round(mae, 4),
        'R²': round(r2, 4)
    }
    print(f"✅ {name} — R²={r2:.3f}, RMSE={rmse:.4f}")

# ── Tableau de résultats ──
import pandas as pd
results_df = pd.DataFrame(results).T.sort_values('R²', ascending=False)
print("\n" + results_df.to_string())
Ce bloc entraîne et compare 5 modèles différents. Pour chacun : on crée un Pipeline (preprocessing + modèle), on l'évalue avec cross-validation 5-fold sur X_train (pour une estimation robuste sans utiliser le test set), puis on évalue aussi sur le test set pour vérifier qu'il généralise bien. La validation croisée 5-fold teste le modèle 5 fois : chaque fold devient tour à tour le validation set, et les 4 autres servent d'entraînement. Les 5 scores sont moyennés pour obtenir une évaluation robuste.
📖 Terme : Validation croisée (k-fold)

Définition : Technique qui divise les données d'entraînement en k groupes (folds), entraîne le modèle k fois en utilisant chaque fold comme validation set et les autres k-1 folds comme train set, puis moyenne les k scores. Avec k=5 : 5 folds × (4 folds entraînement + 1 fold validation).

But : Obtenir une évaluation plus robuste et fiable du modèle sur des données limitées, sans gaspiller les données en test set statique unique.

Pourquoi ici : Un simple train/test split peut être trompeur si vous avez de la malchance avec le tirage aléatoire (un fold validation "facile" ou "difficile"). La validation croisée fait k expériences indépendantes et moyenne les résultats — bien plus stable et recommandé en pratique. C'est le standard pour évaluer les modèles avant d'utiliser le test set final.

Résultats typiques sur California Housing (20 640 samples) : Note : CV RMSE et Test RMSE proches indique pas d'overfitting.
📖 Terme : Surapprentissage (overfitting)

Définition : Situation où un modèle apprend le bruit et les particularités spécifiques des données d'entraînement au lieu de patterns généraux. Il obtient une excellente performance sur train mais mauvaise sur test.

But : Reconnaître et éviter l'overfitting en comparant la performance train vs test et validation croisée.

Pourquoi ici : L'overfitting est le piège principal en Machine Learning. Un modèle peut sembler excellent sur les données d'entraînement (R²=0.99) mais être complètement inutile en production (R²=0.2). Ici, on utilise validation croisée et test set indépendant pour le détecter rapidement.

Analogie : Comme mémoriser les réponses d'un examen d'entraînement sans comprendre le sujet — vous êtes parfait sur cet examen but échouez sur un nouvel examen d'interro surprise.

4. Optimiser le meilleur modèle

📖 Terme : Hyperparamètre

Définition : Un paramètre d'un modèle qu'on doit définir AVANT d'entraîner (contrairement aux poids/coefficients qui sont appris pendant l'entraînement). Exemples : nombre d'arbres dans Random Forest (n_estimators), profondeur max des arbres (max_depth), taux d'apprentissage (learning_rate) en Gradient Boosting, force de régularisation (alpha) en Ridge.

But : Contrôler la complexité, la vitesse et le comportement du modèle.

Pourquoi ici : Les hyperparamètres peuvent faire varier la performance de 10x ou plus. Les optimiser correctement est crucial pour obtenir le meilleur modèle. La plupart du temps en ML, c'est l'optimisation des hyperparamètres qui fait la différence.

4_optimisation.py
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.inspection import permutation_importance

# ── Recherche des hyperparamètres ──
param_grid = {
    'model__n_estimators': [100, 200],
    'model__max_depth': [3, 5, 7],
    'model__learning_rate': [0.05, 0.1, 0.2],
    'model__subsample': [0.8, 1.0]
}

best_pipe = Pipeline([
    ('preprocessor', preprocessor),
    ('model', GradientBoostingRegressor(random_state=42))
])

grid_search = GridSearchCV(
    best_pipe, param_grid,
    cv=5, scoring='neg_root_mean_squared_error',
    n_jobs=-1, verbose=1
)
grid_search.fit(X_train, y_train)

print(f"Meilleurs params : {grid_search.best_params_}")
print(f"Meilleur score CV : {-grid_search.best_score_:.4f}")

# GridSearchCV teste toutes les combinaisons de params : 2 × 3 × 3 × 2 = 36 modèles

# ── Importance des features ──
best_model = grid_search.best_estimator_
y_pred_final = best_model.predict(X_test)

perm_importance = permutation_importance(
    best_model, X_test, y_test, n_repeats=10, random_state=42
)
importance_df = pd.DataFrame({
    'feature': X.columns,
    'importance': perm_importance.importances_mean
}).sort_values('importance', ascending=False)
print("\nImportance des features :")
print(importance_df.head(8))
📖 Terme : GridSearchCV

Définition : Utilitaire scikit-learn qui teste toutes les combinaisons possibles de hyperparamètres dans une grille définie, en utilisant validation croisée pour évaluer chaque combinaison, et retourne les meilleurs paramètres avec les meilleure performance.

But : Optimiser automatiquement les hyperparamètres sans guesswork ni essai-erreur manuel.

Pourquoi ici : C'est la façon standard et systématique de sélectionner les meilleurs hyperparamètres. Le "grid search" teste le produit cartésien de tous les params — ex: [100,200] × [3,5,7] × [0.05,0.1,0.2] × [0.8,1.0] = 2×3×3×2 = 36 combinaisons. Chaque combinaison est évaluée en validation croisée 5-fold, donc 36 × 5 = 180 modèles entraînés au total.

GridSearchCV teste TOUTES les 36 combinaisons de hyperparamètres (le produit cartésien) avec validation croisée 5-fold pour chacun. Pour chaque combinaison, il entraîne le modèle 5 fois sur différents splits train/validation et moyenne le score. Ensuite, il retourne la combinaison avec le meilleur score CV moyen. C'est brute-force mais garantit de trouver la meilleure combinaison dans la grille définie. Avec n_jobs=-1, les 180 modèles sont entraînés en parallèle sur tous les cores du CPU.
📖 Terme : Importance des variables (feature importance)

Définition : Mesure de l'impact de chaque feature sur les prédictions du modèle. Il y a plusieurs façons de la calculer : importance basée sur les splits d'arbres (gain d'information), ou permutation_importance qui mesure la baisse de performance si on brouille aléatoirement cette feature.

But : Comprendre quelles features contribuent le plus aux prédictions et identifier les features inutiles.

Pourquoi ici : L'importance des features aide à interpréter le modèle black-box (Gradient Boosting) et à identifier les features qu'on pourrait supprimer sans perte de performance. Permutation_importance est model-agnostic et plus fiable que l'importance basée sur l'arbre.

5. Sauvegarder et réutiliser le modèle

5_deploiement.py
import joblib
import numpy as np

# ── Sauvegarder le pipeline complet ──
joblib.dump(best_model, 'housing_model.pkl')
print("✅ Modèle sauvegardé")

# ── Charger et utiliser ──
loaded_model = joblib.load('housing_model.pkl')

# Prédire pour une nouvelle maison
# [MedInc, HouseAge, AveRooms, AveBedrms, Population, AveOccup, Lat, Long, ...]
new_house = pd.DataFrame([{
    'MedInc': 5.5,        # Revenu médian $55K
    'HouseAge': 15,       # 15 ans
    'AveRooms': 6.0,
    'AveBedrms': 1.1,
    'Population': 1500,
    'AveOccup': 3.0,
    'Latitude': 37.5,
    'Longitude': -122.0,
    'rooms_per_household': 2.0,
    'bedrooms_ratio': 0.18,
    'population_per_household': 500,
    'income_per_room': 0.92,
    'log_population': np.log1p(1500)
}])

predicted_price = loaded_model.predict(new_house)[0]
print(f"Prix prédit : ${predicted_price * 100_000:,.0f}")
Ce bloc montre comment sauvegarder le modèle entraîné avec joblib (format .pkl), puis le charger et l'utiliser pour faire des prédictions en production. Tout le Pipeline — preprocessing (imputation + normalisation) + modèle (Gradient Boosting) — est sérialisé dans un seul fichier. Cela assure que les prédictions futures utiliseront exactement la même normalisation et le même modèle, éliminant les bugs de disparité train/prod.
Sauvegarder le Pipeline complet (pas juste le modèle) est crucial pour la production. Si on sauvegardait juste le modèle sans le preprocessing, on devrait se souvenir de normaliser chaque nouvelle prédiction manuellement avant de l'envoyer au modèle — source garantie d'erreurs (oublier la normalisation ou utiliser mauvaise moyenne/std).
📖 Terme : Régularisation

Définition : Technique qui ajoute une pénalité aux coefficients du modèle lors de l'entraînement pour le forcer à rester simple et éviter l'overfitting. Ridge (L2) ajoute la somme des carrés des coefficients, Lasso (L1) ajoute la somme des valeurs absolues.

But : Réduire l'overfitting en pénalisant les modèles trop complexes avec trop de poids élevés.

Pourquoi ici : Ridge et Lasso sont les modèles régularisés basiques pour la régression linéaire. Ridge ajoute une pénalité douce (garder tous les coefficients mais réduire leur magnitude), Lasso peut forcer certains coefficients à zéro exactement (sélection automatique de features). L2 (Ridge) est plus stable numériquement, L1 (Lasso) est plus interprétable (zéros naturels).