La respiration de Paris I : acquisition des données Vélib avec Python et Pandas

22 June 2021

Avec le retour des beaux jours et la fin des mesures de confinement, le temps est particulièrement propice aux déplacements à vélo dans la capitale. Comme une bonne partie de la population parisienne, je ne possède pas moi-même de voiture ni de vélo. Pour me déplacer, j'utilise soit les transports en communs soit les Vélibs quand la météo est clémente. Les Vélibs, ce sont les vélos en libre-service de Paris. Le principe est simple : un utilisateur retire un vélo dans une station et le dépose dans une autre station située à proximité de sa destination. Ce service fonctionne grâce à la forte densité de stations - environ $1400$ à l'heure où j'écris ces lignes - ce qui signifie qu'il y a toujours une station à proximité.

Dans un monde idéal, la distribution des vélibs devrait rester homogène dans l'espace. Pour tout voyageur allant de $A$ vers $B$, on s'attendrait à trouver un autre voyageur effectuant le trajet inverse de $B$ vers $A$. Dans ce monde idéal, le nombre d'utilisateurs du réseau à un instant $t$ serait uniquement limité par le nombre total de Vélibs. Néanmoins, Paris est - comme la plupart des villes - très inhomogène : quartiers d'affaires, quartiers résidentiels, quartier étudiants et quartiers branchés... La population parisienne se déplace constamment, de manière massive et régulière, entre ces différents quartiers. Aujourd'hui, je vous propose d'observer cette respiration de Paris en utilisant les données des stations Vélibs. Le but de ce billet est d'utiliser Python et ses librairies de manière ludique. Il ne s'agit pas, bien sûr, d'offrir ici une étude géographique sérieuse. En n'utilisant que les données d'utilisateurs velib, nous avons un biais énorme sur la population parisienne !

Données vélibs : peut-on connaître tous mes déplacements ?

Quand vous entendez données libres, votre premier réflexe devrait être de vous demander quelles sont les données accessibles et si celles-ci constituent une atteinte à la vie privée des utilisateurs. En la matière, je dois dire que l'API offerte par Vélib semble suivre de bonnes pratiques :

  • Les données sont en libre accès et sans clef. Ainsi tout le monde peut accéder à ces données
  • Pour obtenir les données, il suffit d'une simple requête GET, c'est-à-dire qu'il suffit de copier l'URL dans le navigateur pour obtenir l'ensemble des informations désirées
  • Les données sont exportées dans au format JSON avec un encodage standard UTF-8 et une documentation très complète
  • Les standards utilisés pour les données spatiales sont renseignés dans la documentation (en l'occurrence WGS84, c'est-à-dire les coordonnées GPS)
  • Les données sont accessibles en temps réel mais sans accès à l'historique
  • Enfin les données sont anonymisées, on va voir comment

Plus concrètement, regardons en détail ce que contiennent ces données avec Python. Pour ce faire, nous allons utiliser le module requests pour lancer une requête sur l'API de Vélib. Notre première requête porte sur les caractéristiques des stations Vélibs

import requests

answer = requests.get("https://velib-metropole-opendata.smoove.pro/opendata/Vélib_Metropole/station_information.json").json()
print(answer.keys())
dict_keys(['lastUpdatedOther', 'ttl', 'data'])

On obtient en retour un fichier JSON, que l'on peut manipuler comme un dictionnaire Python, avec 3 clefs : lastUpdatedOther l'indication de la date de dernière mise à jour des données, ttl la durée de vie des données avant de devenir obsolète et enfin data les données proprement dîtes.

Pour manipuler ces données, nous allons utiliser le module Pandas. C'est un outil puissant d'analyse de données dont la base est l'objet DataFrame

import pandas as pd

df_1 = pd.DataFrame(answer["data"]["stations"])
df_1.head()
station_id name lat lon capacity stationCode rental_methods
0 213688169 Benjamin Godard - Victor Hugo 48.865983 2.275725 35 16107 NaN
1 99950133 André Mazet - Saint-André des Arts 48.853756 2.339096 55 6015 [CREDITCARD]
2 516709288 Charonne - Robert et Sonia Delauney 48.855908 2.392571 20 11104 NaN
3 36255 Toudouze - Clauzel 48.879296 2.337360 21 9020 [CREDITCARD]
4 37815204 Mairie du 12ème 48.840855 2.387555 30 12109 NaN

En fait, un DataFrame n'est rien d'autre qu'une table de base de données. On voit que pour chaque station, nous disposons de son nom, de son identifiant unique station_id et de sa position en latitude lat et longitude lon. Ce sont des données à durée de vie longue que nous allons garder sous le bras. On ne s'attend pas à ce que des stations se déplacent ou bien changent de nom toutes les $5$ minutes.

Une requête que nous allons effectuer très régulièrement concerne l'occupation des stations :

answer = requests.get("https://velib-metropole-opendata.smoove.pro/opendata/Vélib_Metropole/station_status.json").json()
df_2 = pd.DataFrame(answer["data"]["stations"])
df_2.head()
stationCode station_id num_bikes_available numBikesAvailable num_bikes_available_types num_docks_available numDocksAvailable is_installed is_returning is_renting last_reported
0 16107 213688169 1 1 [{'mechanical': 1}, {'ebike': 0}] 34 34 1 1 1 1624378105
1 6015 99950133 23 23 [{'mechanical': 23}, {'ebike': 0}] 28 28 1 1 1 1624378155
2 11104 516709288 4 4 [{'mechanical': 2}, {'ebike': 2}] 16 16 1 1 1 1624378138
3 9020 36255 1 1 [{'mechanical': 1}, {'ebike': 0}] 18 18 1 1 1 1624377889
4 12109 37815204 15 15 [{'mechanical': 15}, {'ebike': 0}] 13 13 1 1 1 1624378124

Pour chaque station, nous obtenons en temps réel (les données sont mises à jour toutes les minutes) le nombre de vélibs mis à disposition, le nombre de places disponibles et même la décomposition entre le nombre de vélos mécaniques et électriques.

Je vous laisse consulter sur la page de documentation les autres requêtes possibles, mais nous avons vu ensemble les principales. Si les données sont disponibles en temps réel, celles-ci ne contiennent aucune mention de l'utilisateur ou des Vélibs présents. Donc il n'est pas possible d'utiliser ces données pour retracer les trajets effectués par les usagers, c'est-à-dire de déterminer que tel vélo a effectué un trajet d'une station $A$ vers une station $B$. Il s'agit d'un choix de la part du gestionnaire de Vélib entre la vie privée des usagers et la liberté offerte aux analystes. En ce qui nous concerne, les données fournies par le service seront amplement suffisantes.

Stockage des données

Maintenant que nous savons récupérer les données Vélib, nous allons pouvoir les stocker sous la forme d'une base de données persistante SQLITE3. Pour faire simple, il s'agit d'une base de données dite légère (mais puissante !) qui se présente sous la forme d'un simple fichier. C'est un format très pratique pour de petits projets comme le notre ou pour la phase de développement de plus gros projets. Avec Pandas, la procédure est limpide :

# On supprimse les données inutiles
del df_1["stationCode"]
del df_1["rental_methods"]
del df_2["numBikesAvailable"]
del df_2["num_bikes_available_types"]
del df_2["numDocksAvailable"]
del df_2["is_installed"]
del df_2["is_returning"]
del df_2["is_renting"]
del df_2["last_reported"]

# On crée un marqueur temporel
time_stamp = pd.Timestamp.now()
df_2["time_stamp"] = time_stamp

# On enregistre sous forme de base SQLITE
df_1.to_sql("localisation", "sqlite:///data.db", if_exists="replace")
df_2.to_sql("stations", "sqlite:///data.db", if_exists="append")

La commande se lit : j'envoie le DataFrame df_1 vers SQL sous le nom de table localisation et dans la base SQLITE data.db. Si la table existe alors on la remplace. Pour les données en temps réel, on ajoute un marqueur temporel qui permet de garder en mémoire l'heure d'acquisition des données. Notez l'option if_exists="append" : à chaque nouvelle acquisition, on ajoute de nouvelles lignes à la table. Concrètement, le marqueur temporel ressemble à ça

time_stamp
Timestamp('2021-06-22 19:08:02.612976')

Le marqueur retient la date et l'heure précise à laquelle j'écris ces lignes. Et ce sera la même chose pour nos données d'occupation des stations Vélib.

Acquisition des données

Il ne nous reste plus qu'à automatiser l'acquisition de données toutes les minutes. Il existe un module python pour ça qui s'appelle APScheduler. Ce module permet l'automatisation et la planification de tâches, et nous utiliserons l'objet BlockingScheduler. Son utilisation est la suivante :

from apscheduler.schedulers.blocking import BlockingScheduler

sched = BlockingScheduler()

@sched.scheduled_job("interval", seconds=5)
def print_date():
    time_stamp = pd.Timestamp.now()
    print(time_stamp)

sched.start()
2021-06-22 19:08:16.030144
2021-06-22 19:08:21.032282
2021-06-22 19:08:26.029912
2021-06-22 19:08:31.030239
2021-06-22 19:08:36.029868
2021-06-22 19:08:41.029918
2021-06-22 19:08:46.029712
2021-06-22 19:08:51.030311

Avec le décorateur @sched.scheduled_job("interval", seconds=5), on indique que la fonction doit être appelée régulièrement avec un intervalle de temps de 5 secondes.

Nous avons maintenant tous les ingrédients pour lancer une acquisition de données. De mon côté, j'ai récupéré les données sur 24 heures entre le 21 et le 22 juin 2021, c'est-à-dire durant la nuit de la fête de la Musique à Paris.

Manipulation des données : sélection

Nous allons maintenant pouvoir jouer avec nos belles données toutes fraiches. Commençons par importer les deux tables dans deux DataFrames distincts

data_stations = pd.read_sql_table("stations", "sqlite:///database.db")
data_localisation = pd.read_sql_table("localisation", "sqlite:///database.db")

data_stations.head()
index stationCode station_id num_bikes_available num_docks_available time_stamp
0 0 16107 213688169 1 34 2021-06-21 17:26:27.825403
1 1 6015 99950133 10 40 2021-06-21 17:26:27.825403
2 2 11104 516709288 1 17 2021-06-21 17:26:27.825403
3 3 9020 36255 4 15 2021-06-21 17:26:27.825403
4 4 12109 37815204 9 18 2021-06-21 17:26:27.825403

Notre table stations contient environ 2 millions de lignes, un index et 5 colonnes : le code de la station stationCode, l'identifiant de la station station_id, le nombre de vélos disponibles num_bikes_available, le nombre de bornes libres num_docks_available ainsi que notre marqueur temporel time__stamp.

Les DataFrames de pandas sont vraiment très facile à manipuler pour qui est familier avec la syntaxe Python : c'est là toute la force de cette librairie. Avec un DataFrame, il devient aisé d'effectuer des opérations classiques sur les bases de données. Par exemple, on peut sélectionner les observations liées à la station numéro 213688169

station_213688169 = data_stations[data_stations["station_id"] == 213688169]
station_213688169
index stationCode station_id num_bikes_available num_docks_available time_stamp
0 0 16107 213688169 1 34 2021-06-21 17:26:27.825403
1402 0 16107 213688169 1 34 2021-06-21 17:27:26.935454
2804 0 16107 213688169 1 34 2021-06-21 17:28:26.940625
4206 0 16107 213688169 1 34 2021-06-21 17:29:26.936032
5608 0 16107 213688169 1 34 2021-06-21 17:30:26.935873
... ... ... ... ... ... ...
2058264 0 16107 213688169 3 32 2021-06-22 18:16:55.748422
2059668 0 16107 213688169 3 32 2021-06-22 18:17:55.748422
2061072 0 16107 213688169 3 32 2021-06-22 18:18:55.748371
2062476 0 16107 213688169 3 32 2021-06-22 18:19:55.748149
2063880 0 16107 213688169 3 32 2021-06-22 18:20:55.748413

1473 rows × 6 columns

On a donc 1473 observations sur cette station. Avec Pandas, on peut aussi faire des graphiques avec ces données. Ainsi le nombre de vélos en fonction de l'heure de la journée s'obtient en quelques lignes

import matplotlib.pyplot as plt
import matplotlib.dates as mdates

fig, ax = plt.subplots()

station_213688169.plot(x="time_stamp", y="num_bikes_available", ax=ax)

# Pour mettre en forme les étiquettes sur l'abscisse
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))

Nous avons ici une station globalement vide durant les horaires de travail, mais qui se remplit progressivement en fin de journée et durant la nuit : elle est probablement située dans un quartier résidentiel. Notez que la nuit d'acquisition correspond à la fête de la musique, ce qui explique l'activité nocturne de cette station.

Manipulation des données : jointure

Bon, c'est bien gentil tout ça, mais la station 213688169, moi, ça ne me dit rien. Pour en savoir plus sur cette station, il faut faire la correspondance avec notre table localisation. Une première approche serait de faire une sélection sur cette autre table avec notre station_id, et cela fonctionne très bien !

data_localisation[data_localisation["station_id"] == 213688169]
index station_id name lat lon capacity
0 0 213688169 Benjamin Godard - Victor Hugo 48.865983 2.275725 35

Cette mystérieuse station se nomme donc Benjamin Godard - Victor Hugo avec une capacité de 35 vélos et nous avons même ses coordonnées géographiques ! On pourrait de la même manière mettre en relation chacune de nos observations avec des sélections, mais ce serait très inefficace. La bonne manière de procéder, bien connue quand on manipule des bases de données, c'est de faire une jointure.

Dit simplement, on va fusionner les deux tables et aligner les lignes qui partagent certaines propriétés. Ici, nous souhaitons mettre en commun les informations sur les stations qui partagent un même identifiant station_id. Il existe plusieurs types de jointures et nous ne nous attarderons pas davantage sur la dénomination exacte. Avec Pandas, la syntaxe est plutôt claire

data_stations = data_stations.merge(data_localisation, on="station_id")

data_stations
index_x stationCode station_id num_bikes_available num_docks_available time_stamp index_y name lat lon capacity
0 0 16107 213688169 1 34 2021-06-21 17:26:27.825403 0 Benjamin Godard - Victor Hugo 48.865983 2.275725 35
1 0 16107 213688169 1 34 2021-06-21 17:27:26.935454 0 Benjamin Godard - Victor Hugo 48.865983 2.275725 35
2 0 16107 213688169 1 34 2021-06-21 17:28:26.940625 0 Benjamin Godard - Victor Hugo 48.865983 2.275725 35
3 0 16107 213688169 1 34 2021-06-21 17:29:26.936032 0 Benjamin Godard - Victor Hugo 48.865983 2.275725 35
4 0 16107 213688169 1 34 2021-06-21 17:30:26.935873 0 Benjamin Godard - Victor Hugo 48.865983 2.275725 35
... ... ... ... ... ... ... ... ... ... ... ...
2065141 1403 9104 129026597 7 13 2021-06-22 18:16:55.748422 1401 Caumartin - Provence 48.874423 2.328469 22
2065142 1403 9104 129026597 5 15 2021-06-22 18:17:55.748422 1401 Caumartin - Provence 48.874423 2.328469 22
2065143 1403 9104 129026597 5 15 2021-06-22 18:18:55.748371 1401 Caumartin - Provence 48.874423 2.328469 22
2065144 1403 9104 129026597 6 14 2021-06-22 18:19:55.748149 1401 Caumartin - Provence 48.874423 2.328469 22
2065145 1403 9104 129026597 5 15 2021-06-22 18:20:55.748413 1401 Caumartin - Provence 48.874423 2.328469 22

2065146 rows × 11 columns

À l'issue de cette fusion, nous disposons d'une table plus grande, avec le même nombre de lignes (et donc d'observations), mais disposant de cinq colonnes supplémentaires issues de la seconde table. Nous avons ici, toutes les données nécessaires pour commencer l'analyse spatiale de ces fameuses données Vélib.

Conclusion

Dans ce premier billet, nous avons vu comment récupérer des données issues d'une API publique, comment les stocker sous la forme d'une base de données et comment manipuler ces données avec des opérations basiques comme la sélection et les jointures. J'espère vous avoir convaincu que Pandas est un outil très puissant qui met à la portée de tout développeur Python la puissance d'un langage de gestion de bases de données comme SQL.

Dans un second billet, je voudrais vous montrer comment mettre en valeur ces précieuses données en utilisant GeoPandas, une librairie dérivée de Pandas pour l'analyse de données spatialisées. Ensemble, nous obtiendrons de bien jolies cartes de Paris !