Ce billet est le dernier de notre série sur les données publiques Vélib. Dans un premier temps, nous avons vu comment acquérir et enregistrer ces données avec Python et Pandas. Et dans un second temps, comment représenter ces données graphiquement avec l'aide de GeoPandas. Ce faisant, nous avons pu "voir" Paris respirer pendant $24$ heures consécutives et identifier à l'oeil nu les différents types de quartier dans la capitale.
Aujourd'hui, je vous propose une approche complètement automatique pour classifier les différents types de quartier dans Paris, en utilisant l'algorithme des $k$-moyennes.
Mais ne brûlons pas les étapes, commençons par importer nos précieuses données. Je vous renvoie aux deux précédents billets pour comprendre ce qui se passe ici.
import pandas as pd
import geopandas as gpd
# Importation des données
districts = gpd.read_file("quartier_paris.geojson")
data_localisation = pd.read_sql_table("localisation", "sqlite:///database.db")
data_observations = pd.read_sql_table("stations", "sqlite:///database.db")
# Fusions des tables localisation et observations
data_merged = data_observations.merge(data_localisation, on="station_id")
# On interprète ces données comme des données géographiques
geo_stations = gpd.GeoDataFrame(
data_merged,
crs="EPSG:4326",
geometry=gpd.points_from_xy(data_merged["lon"], data_merged["lat"])
)
# On enlève les colonnes inutiles
geo_stations.drop(columns=["stationCode", "name", "lon", "lat", "index_x", "index_y"], inplace=True)
districts.drop(columns=["n_sq_qu", "n_sq_ar", "c_qu", "surface", "perimetre", "c_quinsee", "c_ar"], inplace=True)
# Notre table contenant les stations
geo_stations.head()
station_id | num_bikes_available | num_docks_available | time_stamp | capacity | geometry | |
---|---|---|---|---|---|---|
0 | 213688169 | 1 | 34 | 2021-06-21 17:26:27.825403 | 35 | POINT (2.27572 48.86598) |
1 | 213688169 | 1 | 34 | 2021-06-21 17:27:26.935454 | 35 | POINT (2.27572 48.86598) |
2 | 213688169 | 1 | 34 | 2021-06-21 17:28:26.940625 | 35 | POINT (2.27572 48.86598) |
3 | 213688169 | 1 | 34 | 2021-06-21 17:29:26.936032 | 35 | POINT (2.27572 48.86598) |
4 | 213688169 | 1 | 34 | 2021-06-21 17:30:26.935873 | 35 | POINT (2.27572 48.86598) |
Le réseau Vélib compte environ $1400$ stations et il nous serait difficile de les étudier toutes en même temps. Nous commençons donc par regrouper les données par quartier et par heure.
# Jointure spatiale : on identifie le quartier de chaque observation
geo_districts = gpd.sjoin(districts, geo_stations, how="left")
# On aggrège les données par quartier et par heure
occupation_data = geo_districts.groupby(["l_qu", "time_stamp"], as_index=False).agg({"num_bikes_available":"sum", "geometry": "first"})
occupation_data.head()
l_qu | time_stamp | num_bikes_available | geometry | |
---|---|---|---|---|
0 | Amérique | 2021-06-21 17:26:27.825403 | 38 | POLYGON ((2.40940 48.88019, 2.40995 48.87952, ... |
1 | Amérique | 2021-06-21 17:27:26.935454 | 38 | POLYGON ((2.40940 48.88019, 2.40995 48.87952, ... |
2 | Amérique | 2021-06-21 17:28:26.940625 | 36 | POLYGON ((2.40940 48.88019, 2.40995 48.87952, ... |
3 | Amérique | 2021-06-21 17:29:26.936032 | 38 | POLYGON ((2.40940 48.88019, 2.40995 48.87952, ... |
4 | Amérique | 2021-06-21 17:30:26.935873 | 37 | POLYGON ((2.40940 48.88019, 2.40995 48.87952, ... |
La syntaxe est ici plus compacte que dans le précédent épisode donc nous allons la décortiquer ensemble. Dans un premier, nous groupons les données en fonction de l_qu
, le nom du quartier et de time_stamp
, c'est-à-dire l'heure d'observation. À l'issue de cette opération, on obtient un objet DataFrameGroupBy
. Notez l'option as_index=False
qui est nécessaire ici.
Puis nous aggrégeons les données avec la méthode .agg()
. En argument, on lui dit qu'on veut sommer le nombre de vélos disponibles dans chaque groupe, et garder la géométrie associée au quartier. On peut ne garder que la première occurence, puisque toutes ces géométries sont identiques.
Nous avons donc l'historique, minute par minute, du nombre de vélibs stationnés dans chaque quartier, que nous pouvons visualiser de la façon suivante
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
fig, ax = plt.subplots(figsize=(8,6))
occupation_data.groupby('l_qu').plot(x="time_stamp", y="num_bikes_available", kind="line", ax=ax, legend=False)
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
plt.show()
Chacune de ces courbes est une série temporelle qui caractérise l'activité du réseau Vélib dans son quartier. Ce diagramme est un peu brouillon, mais on peut distinguer à l'oeil quelques tendances. Certaines stations semblent se vider en journée quand d'autres semblent se remplir dans le même interval. Mais peut-on faire mieux et de manière automatique ?
La question que l'on se pose est la suivante : "Est-il possible de classer ces séries temporelles de manières automatique ?". La réponse est oui, et pour cela nous allons utiliser l'algorithme des $k$-moyennes. Cet algorithme permet de diviser des données en $k$ partitions. Sa particularité est de pouvoir être mis en place sans supervisition. C'est à dire qu'il va apprendre tout seul à faire la distinction entre les différentes partitions sans que nous ayons besoin d'intervenir, et c'est précisément ce qu'on lui demande.
D'un point de vue formel, nous commençons avec un ensemble de points $(x_1, x_2, ..., x_n)$ que l'on cherche à partionner en $k$ ensembles notés $(S_1, ... S_k)$. L'algorithme des $k$-moyennes est une heuristique ayant pour but de minimiser $$ \sum_{i=1}^k \sum_{x_j \in S_i} || x_j - \mu_i ||^2, $$ où $\mu_i$ est le barycentre de $S_i$. Dit autrement, on veut construire $k$ ensembles disjoints au sein desquels les points sont les plus groupés. C'est un problème d'optimisation compliqué en théorie et l'algorithme des $k$-moyennes est une méthode efficace pour trouver une décomposition, mais il ne garantit pas que ce soit la solution optimale. Cette décomposition sera tout de même suffisante pour notre cas d'étude.
L'algorithme fonctionne de la manière suivante :
Nous utiliserons la librairie tslearn
, littéralement "apprentissage pour les séries temporelles", qui repose notamment sur sklearn
, une librairie très connue de machine learning. Elle va nous permettre d'appliquer l'algorithme des $k$-moyennes où chaque $x_i$ correspond à une de nos séries temporelles et où la norme $||x_j - \mu_i(t)||$ est la norme euclidienne. La norme euclidienne veut simplement dire que l'on compare nos deux séries terme à terme, soit à même temps. Il existe des normes plus élaborées comme la norme DTW
pour "Dynamic Time Warping" qui sont fréquemment utilisées dans l'analyse des séries temporelles, et sont surtout adaptées à des séries temporelles qui ne seraient pas parfaitement synchrones. Dans le cas présent, il serait contre-productif de choisir une telle norme, d'une part parce qu'elle est beaucoup plus lente à déterminer, et d'autre part parce que nous souhaitons avoir des classes parfaitement synchronisées.
Commençons l'analyse en mettant nos données sous la forme de séries temporelles pour la librairie tslearn
from tslearn.utils import to_time_series_dataset
from tslearn.preprocessing import TimeSeriesScalerMeanVariance
labels, time_series = [] , []
for l_qu, group in occupation_data.groupby("l_qu"):
labels.append(l_qu)
time_series.append(group["num_bikes_available"].array)
# On formatte les séries temporelles
time_series = to_time_series_dataset(time_series)
# On normalise ces mêmes séries
time_series = TimeSeriesScalerMeanVariance().fit_transform(time_series)
time_series.shape
(80, 1473, 1)
Rien de particulier ici, si ce n'est que la fonction to_time_series_dataset
crée un jeu de données pour tslearn
à partir d'un tableau, et que nous normalisons ces données avec l'aide de TimeSeriesScalerMeanVariance
qui fait en sorte que chaque série ait pour moyenne $0$ et variance $1$. Cette étape est très importante puisqu'elle permet de s'affranchir de nombreux biais comme le nombre absolu de vélibs par quartier, ou la proportion d'habitants utilisant le réseau.
On peut maintenant lancer l'apprentissage non-supervisé de notre modèle avec l'algorithme des $k$-moyennes
from tslearn.clustering import TimeSeriesKMeans
n_classes = 3
model = TimeSeriesKMeans(n_clusters=n_classes, metric="euclidean")
model.fit(time_series)
TimeSeriesKMeans()
Visualisons les barycentres (en rouge) et les différents éléments (gris) de chaque classe que notre modèle a trouvé
import matplotlib.dates as mdates
fig = plt.figure(figsize=(8,n_classes * 3))
# On récupère les dates des différentes observations
time_labels = occupation_data["time_stamp"].unique()
# Pour chaque classe
for yi in range(n_classes):
ax = fig.add_subplot(n_classes, 1, 1 + yi)
# On sélectionne les séries qui correspondent à cette classe
for xx in time_series[model.labels_ == yi]:
ax.plot(time_labels, xx, "k-", alpha=.2)
# Le barycentre de la classe
ax.plot(time_labels, model.cluster_centers_[yi].ravel(), "r-")
# Pour formatter l'heure des observations
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
plt.show()
Notre algortihme a classé automatiquement tous les quartiers en trois catégories bien distinctes que l'on peut interpréter comme étant :
Maintenant que nous avons déterminé la classe de chaque quartier, nous pouvons l'ajouter à notre liste des quartiers de Paris
interpretation = ["Affaire", "Résidentiel", "Nocturne"]
classification = pd.DataFrame({"l_qu":labels, "type": [interpretation[classe] for classe in model.labels_] })
districts_classified = districts.merge(classification, on="l_qu")
districts_classified.head()
l_qu | geometry | type | |
---|---|---|---|
0 | Vivienne | POLYGON ((2.34123 48.86580, 2.34118 48.86575, ... | Affaire |
1 | Enfants-Rouges | POLYGON ((2.36710 48.86163, 2.36727 48.86095, ... | Nocturne |
2 | Saint-Germain-des-Prés | POLYGON ((2.33696 48.85381, 2.33699 48.85290, ... | Nocturne |
3 | Saint-Vincent-de-Paul | POLYGON ((2.36051 48.87599, 2.36021 48.87582, ... | Résidentiel |
4 | Saint-Ambroise | POLYGON ((2.37094 48.85780, 2.36823 48.85692, ... | Nocturne |
Et produire la carte des types de quartiers dans Paris à partir des données Vélibs
import contextily as ctx
fig, ax = plt.subplots(figsize=(8, 6))
districts_classified = districts_classified.to_crs(epsg=3857)
districts_classified.plot("type", ax=ax, alpha=0.5, edgecolor="white", legend=True)
ax.set_axis_off()
ctx.add_basemap(ax)
plt.show()
Je vous laisse le soin de me dire ce que vous en pensez !
Cette carte marque la fin de cette série sur les données publiques du réseau Vélib. Cette série a été pour moi un chouette exercice pour mettre en application plein d'outils différents
J'espère que vous avez apprécié, je vous souhaite une bonne journée !
Hébergeur : O2Switch
Le présent site ne fait l’objet d’aucune déclaration à la CNIL car aucune information personnelle n’est collectée.