Aller au contenu

TP 6 – Combien va coûter cette course ?

  • 30 min · 11h10 – 11h40
  • Notebook : jour2/TP6_regression.ipynb · corrigé Scala : scala/tp6_regression.scala
  • Données : data/trips.parquet, data/taxi_zone_lookup.csv

Objectif : prédire fare_amount à partir de la distance, de l’heure, du nombre de passagers et de l’arrondissement de départ. Comparer une régression linéaire et un arbre de régression.

Réussi si : RMSE de l'arbre < 5 $ sur le test.

Ajouter heure (F.hour("tpep_pickup_datetime")) et Borough (jointure avec les zones), garder les courses de distance > 0. Colonnes à garder : trip_distance, heure, passenger_count, Borough, fare_amount.

train, test = df.randomSplit([0.8, 0.2], seed=42). Affichez les deux effectifs.

StringIndexer (Borough → index, handleInvalid="keep") → OneHotEncoder → VectorAssembler.

Indice : VectorAssembler(inputCols=["trip_distance", "heure", "passenger_count", "borough_vec"], outputCol="features"). Les modèles cherchent par défaut une colonne nommée features.

4. Un pipeline par modèle : LinearRegression, puis DecisionTreeRegressor(maxDepth=6)

Section intitulée « 4. Un pipeline par modèle : LinearRegression, puis DecisionTreeRegressor(maxDepth=6) »

Indice : Pipeline(stages=preparation + [modele]).fit(train), puis .transform(test). La préparation est commune, seule la dernière étape change.

Indice : RegressionEvaluator(labelCol="fare_amount", metricName="rmse"), puis metricName="r2". Lequel est le meilleur ? Pourquoi ? Pensez au palier à 70 $ du TP 5 : quel arrondissement contient JFK ?

6. Sauvegarder le meilleur pipeline, puis le recharger

Section intitulée « 6. Sauvegarder le meilleur pipeline, puis le recharger »

model.write().overwrite().save("out/modele_montant"), puis PipelineModel.load(...).

Bonus 1 – GBTRegressor fait-il mieux ? Et un arbre avec la distance seule ?

Section intitulée « Bonus 1 – GBTRegressor fait-il mieux ? Et un arbre avec la distance seule ? »

Que vous apprend la comparaison sur l’utilité de l’heure et des passagers ?

Ajoutez RatecodeID (1 = tarif normal, 2 = forfait JFK) aux features. Que devient la RMSE ?

Ajoutez total_amount aux features. Quel R² ? Pourquoi ce modèle est-il inutilisable ? Et pourquoi le R² n’atteint-il pas tout à fait 1 ? Indice : que contient total_amount en plus du tarif ?

from pyspark.sql import SparkSession, functions as F
from pyspark.ml import Pipeline, PipelineModel
from pyspark.ml.feature import StringIndexer, OneHotEncoder, VectorAssembler
from pyspark.ml.regression import LinearRegression, DecisionTreeRegressor, GBTRegressor
from pyspark.ml.evaluation import RegressionEvaluator
# 1. Features et label
zones = spark.read.csv("data/taxi_zone_lookup.csv", header=True, inferSchema=True)
df = (spark.read.parquet("data/trips.parquet")
.filter("trip_distance > 0") # courses annulées / erreurs GPS
.withColumn("heure", F.hour("tpep_pickup_datetime"))
.join(zones.select(F.col("LocationID").alias("PULocationID"), "Borough"), "PULocationID")
.select("trip_distance", "heure", "passenger_count", "Borough", "fare_amount"))
# 2. Train / test
train, test = df.randomSplit([0.8, 0.2], seed=42)
# 3. Préparation commune
preparation = [
StringIndexer(inputCol="Borough", outputCol="borough_idx", handleInvalid="keep"), # texte → index
OneHotEncoder(inputCols=["borough_idx"], outputCols=["borough_vec"]), # index → vecteur 0/1
VectorAssembler(inputCols=["trip_distance", "heure", "passenger_count", "borough_vec"],
outputCol="features"), # tout dans une colonne
]
# 4 et 5. Deux modèles, même pipeline : seule la dernière étape change
def evaluer(nom, estimateur):
model = Pipeline(stages=preparation + [estimateur]).fit(train)
pred = model.transform(test)
rmse = RegressionEvaluator(labelCol="fare_amount", metricName="rmse").evaluate(pred)
r2 = RegressionEvaluator(labelCol="fare_amount", metricName="r2").evaluate(pred)
print(f"{nom:22s} RMSE = {rmse:5.2f} $ R² = {r2:.3f}")
return model, pred
lr_model, lr_pred = evaluer("Régression linéaire", LinearRegression(labelCol="fare_amount"))
dt_model, dt_pred = evaluer("Arbre (maxDepth=6)", DecisionTreeRegressor(labelCol="fare_amount", maxDepth=6))
dt_pred.select("trip_distance", "heure", "Borough", "fare_amount", F.round("prediction", 2).alias("prediction")).show(5)
print("\n".join(dt_model.stages[-1].toDebugString.splitlines()[:4])) # « If (feature 0 <= 5.96) »
# 6. Persistance
dt_model.write().overwrite().save("out/modele_montant")
recharge = PipelineModel.load("out/modele_montant")
Modèle (test)RMSER²
Régression linéaire≈ 5,34 $≈ 0,914
Arbre, maxDepth=6≈ 4,66 $≈ 0,935

L’arbre gagne : il capte le palier du forfait JFK (70 $) que la droite ne peut pas modéliser. Le premier découpage de l’arbre est « distance ≤ 5,96 miles » : la distance d’abord.

# Bonus 1 – GBT : des arbres en série, chacun corrige les erreurs du précédent (plus long)
evaluer("GBT (20 itérations)", GBTRegressor(labelCol="fare_amount", maxIter=20, maxDepth=5))
# Arbre avec la distance seule : ≈ 4,8 $ contre ≈ 4,7 $ → heure et passagers apportent très peu
seule = Pipeline(stages=[VectorAssembler(inputCols=["trip_distance"], outputCol="features"),
DecisionTreeRegressor(labelCol="fare_amount", maxDepth=6)]).fit(train).transform(test)
# Bonus 2 – RatecodeID (2 = forfait JFK) : RMSE ≈ 4,7 $ → ≈ 2,4 $
# Le select de l'étape 1 a retiré RatecodeID : on relit le Parquet et on refait le même split (seed=42)
df_rc = (spark.read.parquet("data/trips.parquet").filter("trip_distance > 0")
.withColumn("heure", F.hour("tpep_pickup_datetime"))
.join(zones.select(F.col("LocationID").alias("PULocationID"), "Borough"), "PULocationID"))
tr_rc, te_rc = df_rc.randomSplit([0.8, 0.2], seed=42)
prep_rc = preparation[:2] + [VectorAssembler(
inputCols=["trip_distance", "heure", "passenger_count", "borough_vec", "RatecodeID"], outputCol="features")]
p_rc = Pipeline(stages=prep_rc + [DecisionTreeRegressor(labelCol="fare_amount", maxDepth=6)]).fit(tr_rc).transform(te_rc)
print("Arbre + RatecodeID RMSE =", round(RegressionEvaluator(labelCol="fare_amount", metricName="rmse").evaluate(p_rc), 2))
# Bonus 3 – total_amount dans les features : R² ≈ 0,976, modèle inutilisable (fuite de données)
df_triche = spark.read.parquet("data/trips.parquet").filter("trip_distance > 0").select("trip_distance", "total_amount", "fare_amount")
tr2, te2 = df_triche.randomSplit([0.8, 0.2], seed=42)
p_triche = Pipeline(stages=[VectorAssembler(inputCols=["trip_distance", "total_amount"], outputCol="features"),
LinearRegression(labelCol="fare_amount")]).fit(tr2).transform(te2)
print("Avec total_amount R² =", round(RegressionEvaluator(labelCol="fare_amount", metricName="r2").evaluate(p_triche), 3))
BonusRésultatLeçon
Distance seuleRMSE ≈ 4,8 $L’heure et les passagers apportent peu au tarif du compteur
+ RatecodeIDRMSE ≈ 2,4 $, erreur divisée par deuxUne seule feature métier vaut mieux que tous les réglages d’hyperparamètres : connaître le métier
+ total_amountR² ≈ 0,976Fuite de données : total_amount contient déjà le tarif et n’est connu qu’après la course. Le R² n’atteint pas 1 car le pourboire, les taxes et les péages varient
SymptômeCauseSolution
Unseen label à la prédictionUne catégorie n’existe que dans le testhandleInvalid="keep" sur StringIndexer
Column features does not existVectorAssembler avec un autre outputColNommer la colonne features, ou préciser featuresCol= au modèle
IllegalArgumentException: requirement failed: Column label must be of type NumericTypeLabel manquant ou textelabelCol="fare_amount" sur l’estimateur
RMSE excellente, trop belletotal_amount ou tip_amount glissé dans les featuresPour chaque feature : la connaîtrai-je au moment de prédire ?
  • Un participant présente son pipeline et ses scores.
  • Pourquoi l’arbre bat-il la régression linéaire ? Indice : 70 $.
  • Bonus : R² = 0,976 avec total_amount. Bonne nouvelle ? Non : une fuite.
  • À retenir : une feature inconnue au moment de prédire = fuite de données.

Dépôt des participants : formations2026/formation-spark-ml (branche participants)

Contenu pédagogique © Ambient IT · 140 Avenue Jean Lolive, 93500 Pantin · formation@ambient-it.net · +33 (0)1 48 10 09 46

Organisme de formation enregistré sous le numéro 11 75 54743 75. Cet enregistrement ne vaut pas agrément de l'État.