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 !
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 :
GET
, c'est-à-dire qu'il suffit de copier l'URL dans le navigateur pour obtenir l'ensemble des informations désiréesJSON
avec un encodage standard UTF-8
et une documentation très complèteWGS84
, c'est-à-dire les coordonnées GPS)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.
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.
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.
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.
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.
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 !
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.