Requêter via des API avec Python

Download nbviewer Onyxia
Binder Open In Colab githubdev

Introduction : Qu’est-ce qu’une API ?

Définition

Pour expliquer le principe d’une API, je vais reprendre le début de la fiche dédiée dans la documentation collaborative utilitR que je recommande de lire :

Une Application Programming Interface (ou API) est une interface de programmation qui permet d’utiliser une application existante pour restituer des données. Le terme d’API peut être paraître intimidant, mais il s’agit simplement d’une façon de mettre à disposition des données : plutôt que de laisser l’utilisateur consulter directement des bases de données (souvent volumineuses et complexes), l’API lui propose de formuler une requête qui est traitée par le serveur hébergeant la base de données, puis de recevoir des données en réponse à sa requête.

D’un point de vue informatique, une API est une porte d’entrée clairement identifiée par laquelle un logiciel offre des services à d’autres logiciels (ou utilisateurs). L’objectif d’une API est de fournir un point d’accès à une fonctionnalité qui soit facile à utiliser et qui masque les détails de la mise en oeuvre. Par exemple, l’API Sirene permet de récupérer la raison sociale d’une entreprise à partir de son identifiant Siren en interrogeant le référentiel disponible sur Internet directement depuis un script R, sans avoir à connaître tous les détails du répertoire Sirene.

À l’Insee comme ailleurs, la connexion entre les bases de données pour les nouveaux projets tend à se réaliser par des API. L’accès à des données par des API devient ainsi de plus en plus commun et est amené à devenir une compétence de base de tout utilisateur de données.

utilitR

Avantages des API

A nouveau, citons la documentation utilitR

Les API présentent de multiples avantages :

  • Les API rendent les programmes plus reproductibles. En effet, grâce aux API, il est possible de mettre à jour facilement les données utilisées par un programme si celles-ci évoluent. Cette flexibilité accrue pour l’utilisateur évite au producteur de données d’avoir à réaliser de multiples extractions, et réduit le problème de la coexistence de versions différentes des données.
  • Grâce aux API, l’utilisateur peut extraire facilement une petite partie d’une base de données plus conséquente.
  • Les API permettent de mettre à disposition des données tout en limitant le nombre de personnes ayant accès aux bases de données elles-mêmes.
  • Grâce aux API, il est possible de proposer des services sur mesure pour les utilisateurs (par exemple, un accès spécifique pour les gros utilisateurs).

utilitR

L’utilisation accrue d’API dans le cadre de stratégies open-data est l’un des piliers des 15 feuilles de route ministérielles en matière d’ouverture, de circulation et de valorisation des données publiques.

Utilisation des API

Citons encore une fois la documentation utilitR

Une API peut souvent être utilisée de deux façons : par une interface Web, et par l’intermédiaire d’un logiciel (R, Python…). Par ailleurs, les API peuvent être proposées avec un niveau de liberté variable pour l’utilisateur :

  • soit en libre accès (l’utilisation n’est pas contrôlée et l’utilisateur peut utiliser le service comme bon lui semble) ;
  • soit via la génération d’un compte et d’un jeton d’accès qui permettent de sécuriser l’utilisation de l’API et de limiter le nombre de requêtes.

utilitR

De nombreuses API nécessitent une authentification, c’est-à-dire un compte utilisateur afin de pouvoir accéder aux données. Dans un premier temps, nous regarderons exclusivement les API ouvertes sans restriction d’accès.
Certains exercices et exemples permettront néanmoins d’essayer des API avec restrictions d’accès.

Requêter une API

Principe général

L’utilisation de l’interface Web est utile dans une démarche exploratoire mais trouve rapidement ses limites, notamment lorsqu’on consulte régulièrement l’API. L’utilisateur va rapidement se rendre compte qu’il est beaucoup plus commode d’utiliser une API via un logiciel de traitement pour automatiser la consultation ou pour réaliser du téléchargement de masse. De plus, l’interface Web n’existe pas systématiquement pour toutes les API.

Le mode principal de consultation d’une API consiste à adresser une requête à cette API via un logiciel adapté (R, Python, Java…). Comme pour l’utilisation d’une fonction, l’appel d’une API comprend des paramètres qui sont détaillées dans la documentation de l’API.

utilitR

Voici les éléments importants à avoir en tête sur les requêtes (j’emprunte encore à utilitR):

  • Le point d’entrée d’un service offert par une API se présente sous la forme d’une URL (adresse web). Chaque service proposé par une API a sa propre URL. Par exemple, dans le cas de l’OpenFood Facts, l’URL à utiliser pour obtenir des informations sur un produit particulier (l’identifiant 737628064502) est https://world.openfoodfacts.org/api/v0/product/737628064502.json
  • Cette URL doit être complétée avec différents paramètres qui précisent la requête (par exemple l’identifiant Siren). Ces paramètres viennent s’ajouter à l’URL, souvent à la suite de ?. Chaque service proposé par une API a ses propres paramètres, détaillés dans la documentation.
  • Lorsque l’utilisateur soumet sa requête, l’API lui renvoie une réponse structurée contenant l’ensemble des informations demandées. Le résultat envoyé par une API est majoritairement aux formats JSON ou XML (deux formats dans lesquels les informations sont hiérarchisées de manière emboitée). Plus rarement, certains services proposent une information sous forme plate (de type csv).

Du fait de la dimension hiérarchique des formats JSON ou XML, le résultat n’est pas toujours facile à récupérer mais python propose d’excellents outils pour cela (meilleurs que ceux de R). Certains packages, notamment json, facilitent l’extraction de champs d’une sortie d’API. Dans certains cas, des packages spécifiques à une API ont été créés pour simplifier l’écriture d’une requête ou la récupération du résultat. Par exemple, le package pynsee propose des options qui seront retranscrites automatiquement dans l’URL de requête pour faciliter le travail sur les données Insee.

Illustration avec une API de l’Ademe pour obtenir des diagnostics energétiques

Le diagnostic de performance énergétique (DPE) renseigne sur la performance énergétique d’un logement ou d’un bâtiment, en évaluant sa consommation d’énergie et son impact en terme d’émissions de gaz à effet de serre.

Les données des performances énergétiques des bâtiments sont mises à disposition par l’Ademe. Comme ces données sont relativement volumineuses, une API peut être utile lorsqu’on ne s’intéresse qu’à un sous-champ des données. Une documentation et un espace de test de l’API sont disponibles sur le site API GOUV1.

Supposons qu’on désire récupérer une centaine de valeurs pour la commune de Villieu-Loyes-Mollon dans l’Ain (code Insee 01450).

L’API comporte plusieurs points d’entrée. Globalement, la racine commune est:

https://koumoul.com/data-fair/api/v1/datasets/dpe-france

Ensuite, en fonction de l’API désirée, on va ajouter des éléments à cette racine. En l’occurrence, on va utiliser l’API field qui permet de récupérer des lignes en fonction d’un ou plusieurs critères (pour nous, la localisation géographique):

L’exemple donné dans la documentation technique est

GET https://koumoul.com/data-fair/api/v1/datasets/dpe-france/values/{field}

ce qui en python se traduira par l’utilisation de la méthode get du package request sur un url dont la structure est la suivante:

  • il commencera par https://koumoul.com/data-fair/api/v1/datasets/dpe-france/values/ ;
  • il sera ensuite suivi par des paramètres de recherche? Le champ {field} commande ainsi généralement par un ? qui permet ensuite de spécifier des paramètres sous la forme nom_parameter=value

A la lecture de la documentation, les premiers paramètres qu’on désire:

  • Le nombre de pages, ce qui nous permet d’obtenir un certain nombre d’échos. On va seulement récupérer 10 pages ce qui correspond à une centaine d’échos. On va néanmoins préciser qu’on veut 100 échos
  • Le format de sortie. On va privilégier le JSON qui est un format standard dans le monde des API. Python offre beaucoup de flexibilité grâce à l’un de ses objets de base, à savoir le dictionnaire (type dict), pour manipuler de tels fichiers
  • Le code commune des données qu’on désire obtenir. Comme on l’a évoqué, on va récupérer les données dont le code commune est 01450. D’après la doc, il convient de passer le code commune sous le format: code_insee_commune_actualise:{code_commune}. Pour éviter tout risque de mauvais formatage, on va utiliser %3A% pour signifier :
  • D’autres paramètres annexes, suggérés par la documentation

Cela nous donne ainsi un URL dont la structure est la suivante:

code_commune="01450"
size = 100
api_root="https://koumoul.com/data-fair/api/v1/datasets/dpe-france/lines"
url_api = f"{api_root}?page=1&after=10&format=json&q_mode=simple&qs=code_insee_commune_actualise" + "%3A%22" + f"{code_commune}" + "%22" + f"&size={size}&select=" + "%2A&sampling=neighbors"

Si vous introduisez cet URL dans votre navigateur, vous devriez aboutir sur un JSON non formaté2. En Python, on peut utiliser requests pour récupérer les données3:

import requests
import pandas as pd

req = requests.get(url_api)
wb = req.json()

Prenons par exemple les 1000 premiers caractères du résultat, pour se donner une idée du résultat et se convaincre que notre filtre au niveau communal est bien passé :

print(req.content[:1000])

b’{“total”: 114,“next”: “https://koumoul.com/data-fair/api/v1/datasets/dpe-france/lines?after=102721&format=json&q_mode=simple&qs=code_insee_commune_actualise%3A%2201450%22&size=100&select=*&sampling=neighbors”,“results”: [\n {“classe_consommation_energie”: “D”,“tr001_modele_dpe_type_libelle”: “Vente”,“annee_construction”: 1947,"_geopoint": “45.927577,5.229832”,“latitude”: 45.927577,“surface_thermique_lot”: 117.16,"_i": 487,“tr002_type_batiment_description”: “Maison Individuelle”,“geo_adresse”: “Rue de la Bombardi8re 01800 Villieu-Loyes-Mollon”,"_rand": 23215,“code_insee_commune_actualise”: “01450”,“estimation_ges”: 53,“geo_score”: 0.56,“classe_estimation_ges”: “E”,“nom_methode_dpe”: “M9thode Facture”,“tv016_departement_code”: “01”,“consommation_energie”: 178,“date_etablissement_dpe”: “2013-06-13”,“longitude”: 5.229832,"_score": null,'

Ici, il n’est même pas nécessaire en première approche d’utiliser le package json, l’information étant déjà tabulée dans l’écho renvoyé (on a la même information pour tous les pays): On peut donc se contenter de pandas pour transformer nos données en DataFrame et geopandas pour convertir en données géographiques :

import pandas as pandas
import geopandas as gpd

def get_dpe_from_url(url):

    req = requests.get(url)
    wb = req.json()
    df = pd.json_normalize(wb["results"])

    dpe = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.longitude, df.latitude), crs = 4326)
    dpe = dpe.dropna(subset = ['longitude', 'latitude'])

    return dpe

dpe = get_dpe_from_url(url_api)
dpe.head(2)

classe_consommation_energie tr001_modele_dpe_type_libelle annee_construction _geopoint latitude surface_thermique_lot _i tr002_type_batiment_description geo_adresse _rand ... classe_estimation_ges nom_methode_dpe tv016_departement_code consommation_energie date_etablissement_dpe longitude _score _id version_methode_dpe geometry
0 D Vente 1947 45.927577,5.229832 45.927577 117.16 487 Maison Individuelle Rue de la Bombardière 01800 Villieu-Loyes-Mollon 23215 ... E Méthode Facture 01 178.00 2013-06-13 5.229832 None LcjWbXgBPUBpe-3dAXo_ NaN POINT (5.22983 45.92758)
2 D Neuf 2006 45.923244,5.223916 45.923244 90.53 689 Maison Individuelle Chemin du Pont-vieux 01800 Villieu-Loyes-Mollon 401672 ... C FACTURE - DPE 01 227.99 2013-06-11 5.223916 None ocjWbXgBPUBpe-3dA3vm V2012 POINT (5.22392 45.92324)

2 rows × 23 columns

Essayons de représenter sur une carte ces DPE avec les années de construction des logements. Avec folium, on obtient la carte interactive suivante:

import seaborn as sns
import folium

palette = sns.color_palette("coolwarm", 8)

def interactive_map_dpe(dpe):

    # convert in number
    dpe['color'] = [ord(dpe.iloc[i]['classe_consommation_energie'].lower()) - 96 for i in range(len(dpe))]
    dpe = dpe.loc[dpe['color']<=7]
    dpe['color'] = [palette.as_hex()[x] for x in dpe['color']]


    center = dpe[['latitude', 'longitude']].mean().values.tolist()
    sw = dpe[['latitude', 'longitude']].min().values.tolist()
    ne = dpe[['latitude', 'longitude']].max().values.tolist()

    m = folium.Map(location = center, tiles='Stamen Toner')

    # I can add marker one by one on the map
    for i in range(0,len(dpe)):
        folium.Marker([dpe.iloc[i]['latitude'], dpe.iloc[i]['longitude']],
                    popup=f"Année de construction: {dpe.iloc[i]['annee_construction']}, <br>DPE: {dpe.iloc[i]['classe_consommation_energie']}",
                    icon=folium.Icon(color="black", icon="home", icon_color = dpe.iloc[i]['color'])).add_to(m)

    m.fit_bounds([sw, ne])

    return m

m = interactive_map_dpe(dpe)
/miniconda/envs/python-ENSAE/lib/python3.9/site-packages/geopandas/geodataframe.py:1472: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
Make this Notebook Trusted to load map: File -> Trust Notebook

On remarque un problème dans les données: un logement qui n’a rien à voir avec les autres. Il faudrait donc idéalement nettoyer un peu le jeu de données pour filtrer en fonction de limites géographiques.

Un des paramètres qui peut permettre ceci est geo_distance. Pour commencer, on va tricher un petit peu pour déterminer les longitudes et latitudes de départ. Idéalement, on récupérerait le découpage de la commune et utiliserait, par exemple, le centroid de cette commune. Cela nécessite néanmoins l’appel à une autre API que nous n’avons pour le moment pas décrite. Nous allons donc nous contenter d’utiliser les longitudes et latitudes du point médian et fixer un rayon de plusieurs kilomètres pour exclure les points aberrants.

x_median = dpe['longitude'].median()
y_median = dpe['latitude'].median()

La documentation nous informe du format à utiliser:

Le format est ’lon,lat,distance’. La distance optionnelle (0 par défaut) et est exprimée en mètres.

param_distance = f'{x_median},{y_median},1000'
print(param_distance)
5.224684,45.92088,1000

Notre requête devient ainsi:

url_api = f"{api_root}?page=1&after=10&format=json&q_mode=simple&qs=code_insee_commune_actualise" + "%3A%22" + f"{code_commune}" + "%22" + f"&size={size}&select=" + "%2A&sampling=neighbors" + f"&geodistance={param_distance}"
print(url_api)
https://koumoul.com/data-fair/api/v1/datasets/dpe-france/lines?page=1&after=10&format=json&q_mode=simple&qs=code_insee_commune_actualise%3A%2201450%22&size=100&select=%2A&sampling=neighbors&geodistance=5.224684,45.92088,1000
dpe_geo_filter = get_dpe_from_url(url_api)
m_geo_filter = interactive_map_dpe(dpe)
/miniconda/envs/python-ENSAE/lib/python3.9/site-packages/geopandas/geodataframe.py:1472: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
Make this Notebook Trusted to load map: File -> Trust Notebook

Un catalogue incomplet d’API existantes

De plus en plus de sites mettent des API à disposition des développeurs et autres curieux.

Pour en citer quelques-unes très connues :

Cependant, il est intéressant de ne pas se restreindre à celles-ci dont les données ne sont pas toujours les plus intéressantes. Beaucoup de producteurs de données, privés comme publics, mettent à disposition leurs données sous forme d’API

L’API DVF : accéder à des données de transactions immobilières simplement

Le site DVF (demandes de valeurs foncières) permet de visualiser toutes les données relatives aux mutations à titre onéreux (ventes de maisons, appartements, garages…) réalisées durant les 5 dernières années.

Un site de visualisation est disponible sur https://app.dvf.etalab.gouv.fr/.

Ce site est très complet quand il s’agit de connaître le prix moyen au mètre carré d’un quartier ou de comparer des régions entre elles. L’API DVF permet d’aller plus loin afin de récupérer les résultats dans un logiciel de traitement de données. Elle a été réalisée par Christian Quest et le code source est disponible sur Github .

Les critères de recherche sont les suivants :

  • code_commune = code INSEE de la commune (ex: 94068)
  • section = section cadastrale (ex: 94068000CQ)
  • numero_plan = identifiant de la parcelle, (ex: 94068000CQ0110)
  • lat + lon + dist (optionnel): pour une recherche géographique, dist est par défaut un rayon de 500m
  • code_postal

Les filtres de sélection complémentaires :

  • nature_mutation (Vente, etc)
  • type_local (Maison, Appartement, Local, Dépendance)

Exercice

Exercice 1 : Exploiter l’API DVF

1️⃣ Rechercher toutes les transactions existantes dans DVF à Plogoff (code commune 29168, en Bretagne). Afficher les clés du JSON et en déduire le nombre de transactions répertoriées.

2️⃣ N’afficher que les transactions portant sur des maisons. Le résultat devrait ressembler au DataFrame suivant:

code_service_ch reference_document articles_1 articles_2 articles_3 articles_4 articles_5 numero_disposition date_mutation nature_mutation ... identifiant_local surface_relle_bati nombre_pieces_principales nature_culture nature_culture_speciale surface_terrain lat lon geom.type geom.coordinates
0 None None None None None None None 000001 2015-06-25 Vente ... None 90 4 S None 277 48.042047 -4.705626 Point [-4.705626, 48.042047]
1 None None None None None None None 000001 2015-09-12 Vente ... None 90 4 S None 615 48.038356 -4.709215 Point [-4.709215, 48.038356]
2 None None None None None None None 000001 2015-05-23 Vente ... None 50 3 S None 170 48.038782 -4.709152 Point [-4.709152, 48.038782]
3 None None None None None None None 000001 2018-09-28 Vente ... None 67 3 S None 610 48.038467 -4.708496 Point [-4.708496, 48.038467]
4 None None None None None None None 000001 2016-03-19 Vente ... None 108 5 S None 251 48.038626 -4.708192 Point [-4.708192, 48.038626]
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
129 None None None None None None None 000001 2019-03-22 Vente ... None 77 3 S None 273 48.039692 -4.702070 Point [-4.70207, 48.039692]
130 None None None None None None None 000001 2018-09-15 Vente ... None 70 6 S None 672 48.039420 -4.699823 Point [-4.699823, 48.03942]
131 None None None None None None None 000001 2018-09-26 Vente ... None 98 7 S None 455 48.038956 -4.700808 Point [-4.700808, 48.038956]
132 None None None None None None None 000001 2015-07-11 Vente ... None 48 4 S None 625 48.037184 -4.700004 Point [-4.700004, 48.037184]
133 None None None None None None None 000001 2015-09-01 Vente ... None 70 4 S None 555 48.037312 -4.712316 Point [-4.712316, 48.037312]

134 rows × 47 columns

3️⃣ Utiliser l’API geo pour récupérer le découpage communal de la ville de Plogoff

ERROR 1: PROJ: proj_create_from_database: Open of /miniconda/envs/python-ENSAE/share/proj failed

nom code codeDepartement codeRegion population geometry
0 Plogoff 29168 29 53 1230 MULTIPOLYGON (((-4.72457 48.03243, -4.72454 48...

4️⃣ Représenter l’histogramme des prix de vente

N’hésitez pas à aller plus loin en jouant sur des variables de groupes par exemple

5️⃣ On va faire une carte des ventes en affichant le prix de l’achat.

Supposons que le DataFrame des ventes s’appelle ventes. Il faut d’abord le convertir en objet geopandas.

ventes = ventes.dropna(subset = ['lat','lon'])
ventes = gpd.GeoDataFrame(ventes, geometry=gpd.points_from_xy(ventes.lon, ventes.lat))
ventes

code_service_ch reference_document articles_1 articles_2 articles_3 articles_4 articles_5 numero_disposition date_mutation nature_mutation ... nombre_pieces_principales nature_culture nature_culture_speciale surface_terrain lat lon geom.type geom.coordinates geom geometry
0 None None None None None None None 000001 2017-09-29 Vente ... 0.0 None None NaN 48.037810 -4.717967 Point [-4.717967, 48.03781] NaN POINT (-4.71797 48.03781)
1 None None None None None None None 000001 2018-07-29 Vente ... 0.0 None None NaN 48.037810 -4.717967 Point [-4.717967, 48.03781] NaN POINT (-4.71797 48.03781)
2 None None None None None None None 000001 2014-10-30 Vente ... NaN T None 1240.0 48.042296 -4.709488 Point [-4.709488, 48.042296] NaN POINT (-4.70949 48.04230)
3 None None None None None None None 000001 2014-10-30 Vente ... NaN T None 630.0 48.043125 -4.706963 Point [-4.706963, 48.043125] NaN POINT (-4.70696 48.04313)
4 None None None None None None None 000001 2015-06-25 Vente ... NaN J None 78.0 48.042232 -4.705553 Point [-4.705553, 48.042232] NaN POINT (-4.70555 48.04223)
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
434 None None None None None None None 000001 2015-09-01 Vente ... NaN T None 1595.0 48.037084 -4.712427 Point [-4.712427, 48.037084] NaN POINT (-4.71243 48.03708)
435 None None None None None None None 000001 2015-09-01 Vente ... 4.0 S None 555.0 48.037312 -4.712316 Point [-4.712316, 48.037312] NaN POINT (-4.71232 48.03731)
436 None None None None None None None 000001 2015-09-01 Vente ... 0.0 S None 555.0 48.037312 -4.712316 Point [-4.712316, 48.037312] NaN POINT (-4.71232 48.03731)
437 None None None None None None None 000001 2015-09-01 Vente ... NaN T None 595.0 48.037271 -4.711856 Point [-4.711856, 48.037271] NaN POINT (-4.71186 48.03727)
438 None None None None None None None 000001 2014-10-30 Vente ... NaN L None 850.0 48.033956 -4.716009 Point [-4.716009, 48.033956] NaN POINT (-4.71601 48.03396)

431 rows × 49 columns

Avant de faire une carte, on va convertir les limites de la commune de Plogoff en geoJSON pour faciliter sa représentation avec folium (voir la doc geopandas à ce propos):

geo_j = plgf.to_json()

Pour représenter graphiquement, on peut utiliser le code suivant (essayez de le comprendre et pas uniquement de l’exécuter).

import folium
import numpy as np

ventes['map_color'] = pd.qcut(ventes['valeur_fonciere'], [0,0.8,1], labels = ['lightblue','red'])
ventes['icon'] = np.where(ventes['type_local']== 'Maison', "home", "")
ventes['num_voie_clean'] = np.where(ventes['numero_voie'].isnull(), "", ventes['numero_voie'])
ventes['text'] = ventes.apply(lambda s: "Adresse: {num} {voie} <br>Vente en {annee} <br>Prix {prix:.0f} €".format(
                        num = s['num_voie_clean'],
                        voie = s["voie"],
                        annee = s['date_mutation'].split("-")[0],
                        prix = s["valeur_fonciere"]),
             axis=1)
             
center = ventes[['lat', 'lon']].mean().values.tolist()
sw = ventes[['lat', 'lon']].min().values.tolist()
ne = ventes[['lat', 'lon']].max().values.tolist()

m = folium.Map(location = center, tiles='Stamen Toner')

# I can add marker one by one on the map
for i in range(0,len(ventes)):
    folium.Marker([ventes.iloc[i]['lat'], ventes.iloc[i]['lon']],
                  popup=ventes.iloc[i]['text'],
                  icon=folium.Icon(color=ventes.iloc[i]['map_color'], icon=ventes.iloc[i]['icon'])).add_to(m)

m.fit_bounds([sw, ne])
# Afficher la carte
m
Make this Notebook Trusted to load map: File -> Trust Notebook

Géocoder des données grâce aux API officielles

Jusqu’à présent, nous avons travaillés sur des données où la dimension géographique était déjà présente ou relativement facile à intégrer.

Ce cas idéal ne se rencontre pas nécessairement dans la pratique. On dispose parfois de localisations plus ou moins précises et plus ou moins bien formattées pour déterminer la localisation de certains lieux.

Depuis quelques années, un service officiel de géocodage a été mis en place. Celui-ci est gratuit et permet de manière efficace de coder des adresses à partir d’une API. Cette API, connue sous le nom de la Base d’Adresses Nationale (BAN) a bénéficié de la mise en commun de données de plusieurs acteurs (collectivités locales, Poste) et de compétences d’acteurs comme Etalab. La documentation de celle-ci est disponible à l’adresse https://api.gouv.fr/les-api/base-adresse-nationale

Pour illustrer la manière de géocoder des données avec Python, nous allons partir de la base des résultats des auto-écoles à l’examen du permis sur l’année 2018.

Ces données nécessitent un petit peu de travail pour être propres à une analyse statistique. Après avoir renommé les colonnes, nous n’allons conserver que les informations relatives au permis B (permis voiture classique) et les auto-écoles ayant présenté au moins 20 personnes à l’examen.

import pandas as pd
import xlrd
import geopandas as gpd

df = pd.read_excel("https://www.data.gouv.fr/fr/datasets/r/d4b6b072-8a7d-4e04-a029-8cdbdbaf36a5", header = [0,1])

index_0 = ["" if df.columns[i][0].startswith("Unnamed") else df.columns[i][0] for i in range(len(df.columns))]
index_1 = [df.columns[i][1] for i in range(len(df.columns))]
keep_index = [True if el in ('', "B") else False for el in index_0] 

cols = [index_0[i] + " " + index_1[i].replace("+", "_") for i in range(len(df.columns))]
df.columns = cols
df = df.loc[:, keep_index]
df.columns = df.columns.str.replace("(^ |°)", "", regex = True).str.replace(" ", "_")
df = df.dropna(subset = ['B_NB'])
df = df.loc[~df["B_NB"].astype(str).str.contains("(\%|\.)"),:]

df['B_NB'] = df['B_NB'].astype(int)
df['B_TR'] = df['B_TR'].str.replace(",", ".").str.replace("%","").astype(float)

df = df.loc[df["B_NB"]>20]
/tmp/ipykernel_1167/1059845781.py:16: UserWarning:

This pattern is interpreted as a regular expression, and has match groups. To actually get the groups, use str.extract.

Sur cet échantillon, le taux de réussite moyen était, en 2018, de 58.02%

Nos informations géographiques prennent la forme suivante:

df.loc[:,['Adresse','CP','Ville']].head(5)

Adresse CP Ville
0 56 RUE CHARLES ROBIN 01000 BOURG EN BRESSE
2 7, avenue Revermont 01250 Ceyzeriat
3 72 PLACE DE LA MAIRIE 01000 SAINT-DENIS LES BOURG
4 6 RUE DU LYCEE 01000 BOURG EN BRESSE
5 9 place Edgard Quinet 01000 BOURG EN BRESSE

Autrement dit, nous disposons d’une adresse, d’un code postal et d’un nom de ville. Ces informations peuvent servir à faire une recherche sur la localisation d’une auto-école.

Utiliser l’API BAN

La documentation officielle de l’API propose un certain nombre d’exemples de manière de géolocaliser des données. Dans notre situation, deux points d’entrée paraissent intéressants:

  • L’API /search/ qui représente un point d’entrée avec des URL de la forme https://api-adresse.data.gouv.fr/search/?q=<adresse>&postcode=<codepostal>&limit=1
  • L’API /search/csv qui prend un CSV en entrée et retourne ce même CSV avec les observations géocodées. La requête prend la forme suivante, en apparence moins simple à mettre en oeuvre : curl -X POST -F data=@search.csv -F columns=adresse -F columns=postcode https://api-adresse.data.gouv.fr/search/csv/

La tentation serait forte d’utiliser la première méthode avec une boucle sur les lignes de notre DataFrame pour géocoder l’ensemble de notre jeu de données. Cela serait néanmoins une mauvaise idée car les communications entre notre session Python et les serveurs de l’API seraient beaucoup trop nombreuses pour offrir des performances satisfaisantes.

Pour vous en convaincre, vous pouvez exécuter le code suivant sur un petit échantillon de données (par exemple 100 comme ici) et remarquer que le temps d’exécution est assez important

import time

dfgeoloc = df.loc[:, ['Adresse','CP','Ville']].apply(lambda s: s.str.lower().str.replace(","," "))
dfgeoloc['url'] = (dfgeoloc['Adresse'] + "+" + dfgeoloc['Ville'].str.replace("-",'+')).str.replace(" ","+")
dfgeoloc['url'] = 'https://api-adresse.data.gouv.fr/search/?q=' + dfgeoloc['url'] + "&postcode=" + df['CP'] + "&limit=1"
dfgeoloc = dfgeoloc.dropna()

start_time = time.time()

def get_geoloc(i):
    print(i)
    return gpd.GeoDataFrame.from_features(requests.get(dfgeoloc['url'].iloc[i]).json()['features'])

local = [get_geoloc(i) for i in range(len(dfgeoloc.head(10)))]
print("--- %s seconds ---" % (time.time() - start_time))

Comme l’indique la documentation, si on désire industrialiser notre processus de géocodage, on va privilégier l’API CSV.

Pour obtenir une requête CURL cohérente avec le format désiré par l’API on va à nouveau utiliser requests mais cette fois avec des paramètres supplémentaires:

  • data va nous permettre de passer des paramètres à CURL (équivalents aux -F de la requête CURL):
    • columns: Les colonnes utilisées pour localiser une donnée. En l’occurrence, on utilise l’adresse et la ville (car les codes postaux n’étant pas uniques, un même nom de voirie peut se trouver dans plusieurs villes partageant le même code postal)
    • postcode: Le code postal de la ville. Idéalement nous aurions utilisé le code Insee mais nous ne l’avons pas dans nos données.
    • result_columns: on restreint les données échangées avec l’API aux colonnes qui nous intéressent. Cela permet d’accélérer les processus (on échange moins de données) et de réduire l’impact carbone de notre activité (moins de transferts = moins d’énergie dépensée). En l’occurrence, on ne ressort que les données géolocalisées et un score de confiance en la géolocalisation.
  • files: permet d’envoyer un fichier via CURL

Les données sont récupérées avec request.post. Comme il s’agit d’une chaîne de caractère, nous pouvons directement la lire avec pandas en utilisant io.StringIO pour éviter d’écrire des données intermédiaires.

Le nombre d’échos semblant être limité, je propose de procéder par morceaux (ici je découpe mon jeu de données en 5 morceaux).

import requests
import io   
import numpy as np
import time

params = {
    'columns': ['Adresse', 'Ville'],
    'postcode': 'CP',
    'result_columns': ['result_score', 'latitude', 'longitude'],
}

df[['Adresse','CP','Ville']] = df.loc[:, ['Adresse','CP','Ville']].apply(lambda s: s.str.lower().str.replace(","," "))

def geoloc_chunk(x):
    dfgeoloc = x.loc[:, ['Adresse','CP','Ville']]
    dfgeoloc.to_csv("datageocodage.csv", index=False)
    response = requests.post('https://api-adresse.data.gouv.fr/search/csv/', data=params, files={'data': ('datageocodage.csv', open('datageocodage.csv', 'rb'))})
    geoloc = pd.read_csv(io.StringIO(response.text), dtype = {'CP': 'str'})
    return geoloc
    
start_time = time.time()
geodata = [geoloc_chunk(dd) for dd in np.array_split(df, 10)]
print("--- %s seconds ---" % (time.time() - start_time))
--- 65.02961325645447 seconds ---

Cette méthode est beaucoup plus rapide et permet ainsi, une fois retourné à nos données initiales, d’avoir un jeu de données géolocalisé

geodata = pd.concat(geodata, ignore_index = True)
df_xy = df.merge(geodata, on = ['Adresse','CP','Ville'])
df_xy = df_xy.dropna(subset = ['latitude','longitude'])
df_xy['text'] = df_xy['Raison_Sociale'] + '<br>' + df_xy['Adresse'] + '<br>' + df_xy['Ville'] + '<br>Nombre de candidats:' + df_xy['B_NB'].astype(str)

df_xy.filter(['Raison_Sociale','Adresse','CP','Ville','latitude','longitude'], axis = "columns").sample(10)

Raison_Sociale Adresse CP Ville latitude longitude
3581 DECLIC conduite 36 rue abbe pasty 45400 fleury les aubrais 47.928850 1.917149
8542 C'PERMIS 86 Fermée rue des freres mongolfier 86000 poitiers 46.591003 0.324507
6027 MIROIR 21 tue de zillisheim 68100 mulhouse 47.743202 7.333111
5283 BALY 46 ter rue de l'aiglon 62126 wimille 50.738420 1.619486
2659 SAINT GILLES ECOLE DE CONDUITE 20 bis rue de la liberte 37220 l ile bouchard 47.122610 0.426086
3268 M CONDUITE_PONT SALOMON rue du rossignol 43330 pont salomon 45.341145 4.247076
2812 4 MONTAGNES SARL AUTO ECOLE 408 avenue du general de gaulle 38250 villard de lans 45.073766 5.554064
7361 BS AUTO ECOLE 1 avenue du lycee 77130 montereau fault yonne 48.394629 2.959266
1815 ZZ CLEMTIM RANGUEIL 13 rue du général bares 31400 toulouse 43.574756 1.462646
637 AUTO-ECOLE CER JURANVILLE 1 bis boulevard juranville 18000 bourges 47.083911 2.388620

Il ne reste plus qu’à utiliser geopandas et nous serons en mesure de faire une carte des localisations des auto-écoles :

import geopandas as gpd
dfgeo = gpd.GeoDataFrame(df_xy, geometry=gpd.points_from_xy(df_xy.longitude, df_xy.latitude))

Nous allons représenter les stations dans l’Essonne avec un zoom initialement sur les villes de Massy et Palaiseau. Le code est le suivant:

import folium

# Représenter toutes les autoécoles de l'Essonne
df_91 = df_xy.loc[df_xy["Dept"] == "091"]

# Centrer la vue initiale sur Massy-Palaiseau
df_pal = df_xy.loc[df_xy['Ville'].isin(["massy", "palaiseau"])]
center = df_pal[['latitude', 'longitude']].mean().values.tolist()
sw = df_pal[['latitude', 'longitude']].min().values.tolist()
ne = df_pal[['latitude', 'longitude']].max().values.tolist()

m = folium.Map(location = center, tiles='Stamen Toner')

# I can add marker one by one on the map
for i in range(0,len(df_91)):
    folium.Marker([df_91.iloc[i]['latitude'], df_91.iloc[i]['longitude']],
                  popup=df_91.iloc[i]['text'],
                  icon=folium.Icon(icon='car', prefix='fa')).add_to(m)

m.fit_bounds([sw, ne])

Ce qui permet d’obtenir la carte:

Make this Notebook Trusted to load map: File -> Trust Notebook