Aller au contenu

TP 8 – Les familles de courses

  • 45 min · 15h35 – 16h20
  • Notebook : jour2/TP8_clustering.ipynb
  • Données : data/trips.parquet, data/taxi_zone_lookup.csv

Objectif : regrouper les courses en familles sans étiquette, avec K-means, à partir de la distance, du montant et de l’heure.

Réussi si : k justifié par la silhouette et 3 familles nommées avec des mots métier.

VectorAssembler sur trip_distance, total_amount, heure, puis StandardScaler(withMean=True, withStd=True). Gardez les courses trip_distance > 0.

2. KMeans(k=k, seed=1) pour k = 2 à 6, silhouette de chaque k

Section intitulée « 2. KMeans(k=k, seed=1) pour k = 2 à 6, silhouette de chaque k »

Indice : ClusteringEvaluator(featuresCol="features", metricName="silhouette").

Indice : groupBy("prediction").agg(count, avg distance, avg montant, avg heure). Si deux k ont une silhouette presque égale, choisissez celui dont les familles ont le plus de sens métier.

Famille 0 : … Famille 1 : … Famille 2 : …

5. Sans StandardScaler : que change-t-il ? Pourquoi ?

Section intitulée « 5. Sans StandardScaler : que change-t-il ? Pourquoi ? »

Indice : regardez l’heure moyenne de chaque centre : [[round(float(v), 1) for v in c] for c in model.stages[-1].clusterCenters()].

Pour aller plus loin : l’heure est traitée comme un nombre, donc 23 h et 0 h sont « loin ». Comment corriger cela ? (Indice : représenter l’heure par son cosinus et son sinus sur un cercle de 24 h.)

Sommets = zones (id), arêtes = courses (src = PULocationID, dst = DOLocationID). Top 5 PageRank. Si le package manque : degrés avec groupBy("dst").count().

from pyspark.sql import functions as F
from pyspark.ml import Pipeline
from pyspark.ml.feature import VectorAssembler, StandardScaler
from pyspark.ml.clustering import KMeans
from pyspark.ml.evaluation import ClusteringEvaluator
# 1. Trois variables, normalisées
df = (spark.read.parquet("data/trips.parquet").filter("trip_distance > 0")
.withColumn("heure", F.hour("tpep_pickup_datetime"))
.select("PULocationID", "DOLocationID", "trip_distance", "total_amount", "heure"))
assembleur = VectorAssembler(inputCols=["trip_distance", "total_amount", "heure"], outputCol="brut")
scaler = StandardScaler(inputCol="brut", outputCol="features", withMean=True, withStd=True)
# 2. k de 2 à 6
silhouette = ClusteringEvaluator(featuresCol="features", metricName="silhouette")
resultats = {}
for k in range(2, 7):
model = Pipeline(stages=[assembleur, scaler, KMeans(k=k, seed=1)]).fit(df)
resultats[k] = silhouette.evaluate(model.transform(df))
print(f"k = {k} : silhouette = {resultats[k]:.3f}")
k_choisi = max(resultats, key=resultats.get) # à confronter au sens métier
# 3. Décrire chaque famille
model = Pipeline(stages=[assembleur, scaler, KMeans(k=k_choisi, seed=1)]).fit(df)
familles = model.transform(df)
(familles.groupBy("prediction")
.agg(F.count("*").alias("courses"), F.round(F.avg("trip_distance"), 1).alias("distance"),
F.round(F.avg("total_amount"), 1).alias("montant"), F.round(F.avg("heure"), 1).alias("heure"))
.orderBy("prediction").show())
# 5. Sans StandardScaler : le montant écrase l'heure
brut = Pipeline(stages=[assembleur, KMeans(k=3, seed=1, featuresCol="brut")]).fit(df)
for c in brut.stages[-1].clusterCenters():
print([round(float(v), 1) for v in c]) # l'heure est identique partout
kSilhouette
3≈ 0,66
6≈ 0,53
FamilleDistanceMontantHeureNom métier
Courtes du soir≈ 2,2 mi≈ 21 $≈ 18 hTrajets urbains de fin de journée
Courtes du matin≈ 2,2 mi≈ 21 $≈ 9 hTrajets urbains du matin
Longues / aéroport≈ 14,1 mi≈ 87 $≈ 14 hAéroports et longues distances

Sans normalisation, les 3 centres ont tous une heure moyenne proche de 14 h : le montant (dizaines de dollars) domine la distance euclidienne, l’heure (0 à 23) ne pèse rien.

zones = spark.read.csv("data/taxi_zone_lookup.csv", header=True, inferSchema=True)
aretes = df.select(F.col("PULocationID").alias("src"), F.col("DOLocationID").alias("dst"))
try:
from graphframes import GraphFrame
g = GraphFrame(zones.withColumnRenamed("LocationID", "id"), aretes)
g.pageRank(resetProbability=0.15, maxIter=10).vertices \
.orderBy(F.desc("pagerank")).select("id", "Zone", "pagerank").show(5)
g.inDegrees.join(zones, g.inDegrees.id == zones.LocationID) \
.orderBy(F.desc("inDegree")).select("Zone", "inDegree").show(5)
except Exception as e: # ImportError ou JAR absent
print("GraphFrames indisponible, mode dégradé :", type(e).__name__)
(aretes.groupBy("dst").count().withColumnRenamed("count", "inDegree")
.join(zones, F.col("dst") == zones.LocationID)
.orderBy(F.desc("inDegree")).select("Zone", "inDegree").show(5))
SymptômeCauseSolution
Les 3 centres ont la même heurePas de StandardScalerNormaliser : c’est le but de l’étape 5
« La silhouette est meilleure sans scaler »Espaces différentsNe pas comparer ; juger les familles au sens métier
java.lang.ClassNotFoundException: org.graphframes...JAR GraphFrames absentspark.jars.packages, ou mode dégradé groupBy
Résultats différents d’un voisinseed différent dans KMeansseed=1
  • Un participant présente ses familles de courses.
  • Quel k avez-vous choisi, et pourquoi ? Silhouette et sens métier.
  • Sans StandardScaler, l’heure n’a joué aucun rôle : pourquoi ? Le montant domine la distance euclidienne.
  • Bonus : PageRank ≠ degré. Une zone peu fréquentée mais reliée à des zones centrales peut être bien classée.
  • À retenir : non supervisé = pas de bonne réponse. Le métier juge l’utilité des groupes.

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.