TP 8 – Les familles de courses
Objectif : regrouper les courses en familles sans étiquette, avec K-means, à partir de la distance, du montant et de l’heure.
1. Préparer les features
Section intitulée « 1. Préparer les features »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").
3. Choisir k, décrire chaque famille
Section intitulée « 3. Choisir k, décrire chaque famille »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.
4. Nommer les familles avec des mots métier
Section intitulée « 4. Nommer les familles avec des mots 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.)
Bonus – GraphFrames : les zones comme un graphe
Section intitulée « Bonus – GraphFrames : les zones comme un graphe »Sommets = zones (id), arêtes = courses (src = PULocationID, dst = DOLocationID). Top 5 PageRank. Si le
package manque : degrés avec groupBy("dst").count().
Correction
Section intitulée « Correction »from pyspark.sql import functions as Ffrom pyspark.ml import Pipelinefrom pyspark.ml.feature import VectorAssembler, StandardScalerfrom pyspark.ml.clustering import KMeansfrom pyspark.ml.evaluation import ClusteringEvaluator
# 1. Trois variables, normaliséesdf = (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 à 6silhouette = 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 famillemodel = 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'heurebrut = 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 partoutRésultats attendus
Section intitulée « Résultats attendus »| k | Silhouette |
|---|---|
| 3 | ≈ 0,66 |
| 6 | ≈ 0,53 |
| Famille | Distance | Montant | Heure | Nom métier |
|---|---|---|---|---|
| Courtes du soir | ≈ 2,2 mi | ≈ 21 $ | ≈ 18 h | Trajets urbains de fin de journée |
| Courtes du matin | ≈ 2,2 mi | ≈ 21 $ | ≈ 9 h | Trajets urbains du matin |
| Longues / aéroport | ≈ 14,1 mi | ≈ 87 $ | ≈ 14 h | Aé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.
Bonus GraphFrames
Section intitulée « Bonus GraphFrames »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))Pièges rencontrés en séance
Section intitulée « Pièges rencontrés en séance »| Symptôme | Cause | Solution |
|---|---|---|
| Les 3 centres ont la même heure | Pas de StandardScaler | Normaliser : c’est le but de l’étape 5 |
| « La silhouette est meilleure sans scaler » | Espaces différents | Ne pas comparer ; juger les familles au sens métier |
java.lang.ClassNotFoundException: org.graphframes... | JAR GraphFrames absent | spark.jars.packages, ou mode dégradé groupBy |
| Résultats différents d’un voisin | seed différent dans KMeans | seed=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.