Skip to article frontmatterSkip to article content

ce TP vise à vous faire découvrir quelques possibilités de manipulation et d’affichage de données géographiques

le résultat final sera de produire une carte comme ceci

géolocalisation

pour obtenir des coordonnées latitude/longitude à partir d’une adresse, en France on peut utiliser un service public et gratuit ici

https://adresse.data.gouv.fr/api-doc/adresse

lisez bien cette page, et notamment tout en bas il y a une zone où vous pouvez faire une recherche en ligne

# for starters, we only need the 'regular fit' pandas

import pandas as pd

vous pouvez charger le fichier data/addresses.csv; toutes ces adresses sont situées à PARIS

# load the data in data/addresses.csv
# and display a few first lines

# your code here

addresses = ...

et la première chose qu’on va faire, c’est naturellement d’utiliser cette API pour géolocaliser ces adresses

mais avant cela, je vous recommande de produire un fichier addresses-small.csv qui contient un petit extrait, disons les 10 ou 20 premières lignes; ce sera très utile pour débugger

# produce a small extract into addresses-small.csv

# your code here

une par une

c’est très pratique de pouvoir faire une recherche des adresses ‘une par une’; voici comment ça se présenterait

# requests is the swiss knife for doing http
import requests

def localize_one(num, typ, nom):

    # we build the URL which directly contains the address pieces
    url = f"https://api-adresse.data.gouv.fr/search/?q={num}+{typ}+{nom},Paris&limit=1"
    print(f"localize_one is fetching page\n{url}")

    # sending request to the web server
    response = requests.get(url)

    # if all is OK, http returns a code in the [200 .. 300[ range
    if not (200 <= response.status_code < 300):
        print("WHOOPS....")
        return

    # we can then read the answer 
    # remember it's a JSON string
    # so we can decode it on the fly
    return response.json()
# and here is how we could use it

localize_one(18, 'rue', 'BERNARDINS')
localize_one is fetching page
https://api-adresse.data.gouv.fr/search/?q=18+rue+BERNARDINS,Paris&limit=1
{'type': 'FeatureCollection', 'features': [{'type': 'Feature', 'geometry': {'type': 'Point', 'coordinates': [2.350728, 48.850345]}, 'properties': {'label': '18 Rue des Bernardins 75005 Paris', 'score': 0.7545049650349649, 'housenumber': '18', 'id': '75105_0896_00018', 'name': '18 Rue des Bernardins', 'postcode': '75005', 'citycode': '75105', 'x': 652355.3, 'y': 6861340.65, 'city': 'Paris', 'district': 'Paris 5e Arrondissement', 'context': '75, Paris, Île-de-France', 'type': 'housenumber', 'importance': 0.68417, 'street': 'Rue des Bernardins', '_type': 'address'}}]}
# try to estimate how long it would take 
# to resolve 20_000 addresses this way

# your code here

en un seul coup

si vous avez bien lu la page qui décrit l’API, vous devez avoir remarqué qu’il y a une autre façon de lui soumettre une recherche

c’est ce qui est indiqué ici (cherchez search/csv dans la page de l’API)

curl -X POST -F data=@path/to/file.csv -F columns=voie -F columns=ville https://api-adresse.data.gouv.fr/search/csv/

mais comment ça se lit ce bidule ?

GET

GET: c’est le comportement par défaut de curl; dans ce mode de fonctionnement les paramètres sont passés directement dans l’URL comme on l’a fait tout à l’heure quand on a vu ceci

localize_one is fetching page
https://api-adresse.data.gouv.fr/search/?q=18+rue+BERNARDINS,Paris&limit=1

POST

POST: dans ce mode-là, on ne passe plus les paramètres dans l’URL, mais dans le header http; bon je sais ça ne vous parle pas forcément, et ce n’est pas hyper important de savoir exactement ce que ça signifie, mais le point important c’est qu’on ne va plus passer les paramètres de la même façon

et donc pour revenir à notre phrase:

curl -X POST -F data=@path/to/file.csv -F columns=voie -F columns=ville https://api-adresse.data.gouv.fr/search/csv/

ce qui se passe ici, c’est qu’on utilise curl pour envoyer une requête POST avec des paramètres data et columns; le bon sens nous dit que

en Python

sauf que nous, on ne veut pas utiliser curl, on veut faire cette requête en Python; voici comment on va s’y prendre

  1. en une seule requête à l’API, on va envoyer tout le fichier csv, en lui indiquant juste quelles sont les colonnes qui contiennent les morceaux de l’adresse

  2. le résultat - toujours au format csv - pourra être également transformé en dataframe

  3. qu’il ne restera plus qu’à merge (ou join si vous préférez) avec la dataframe de départ, pour ajouter les résultats de la géolocalisation dans les données de départ pour cette étape on peut envisager de ne garder que certaines colonnes de la géolocalisation (assez bavarde par ailleurs), je vous recommande de conserver uniquement:

    • latitude, longitude - of course
    • result_city pour pouvoir vérifier la validité des résultats - ici on devrait toujours avoir Paris
    • result_type qui devrait toujours renvoyer housenumber, ça permet à nouveau de vérifier qu’on a bien une adresse connue

je vous recommande d’y aller pas à pas, commencez par juste l’étape 1, puis 1 et 2, et enfin de 1 à 3

c’est utile aussi de commencer par une toute petite dataframe pour ne pas attendre des heures pendant la mise au point...

# your code here

def localize_many(filename, col_number, col_type, col_name, col_city):
    """
    calls the https://api-adresse.data.gouv.fr API
    and returns an augmented dataframe with 4 new columns
    latitude, longitude, result_city and result_type

    Parameters:
      filename:
        the name of the input csv file
      col_number:
      col_type:
      col_name:
      col_city:
        you must provide the names of the 4 columns where to find
        street number, street type, street name and city name
        to be used for geolocating
    """

    pass
# try your code on the small sample for starters

addresses_small = localize_many("addresses-small.csv", "number", "type", "name", "city")
addresses_small
# sanity check : make sure that all the entries have 
# result_city == 'Paris'
# and
# result_type == 'housenumber'

# your code
# when you think you're ready, go full scale
# be ready to wait for at least 40-60s 

# optional: try to record the time it takes !
# tip: see for example time.time()

# addresses = localize_many("data/addresses.csv", "number", "type", "name", "city")
# sanity check

# len(addresses)
# at this point you should store the data
# it's just good practice, as you've done one important step of the process

# store geolocalized addresses in addresses-geoloc.csv

# your code

afficher sur une carte

à présent qu’on a une position, on va pouvoir afficher ces adresses sur une carte

et pour ça il y a plein de libs disponibles, on va choisir folium

si nécessaire, il faut l’installer (comment on fait déjà ?)

import folium

pour commencer, allez chercher la documentation; le plus simple c’est de demander à google folium python

Question subsidiaire: comment j’ai fait d’après vous pour embarquer cette recherche dans un lien hypertexte ?

et surtout (regardez les deux premiers pour l’instant):

# ~ chatelet

CENTER = 48.856542, 2.347614

le fond de carte

# pour commencer on va recharger la dataframe précédente
import pandas as pd

addresses = pd.read_csv("data/addresses-geoloc.csv")

# et en faire un petit échantillon

addresses_small = addresses.iloc[:20]
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[15], line 4
      1 # pour commencer on va recharger la dataframe précédente
      2 import pandas as pd
----> 4 addresses = pd.read_csv("data/addresses-geoloc.csv")
      6 # et en faire un petit échantillon
      8 addresses_small = addresses.iloc[:20]

File ~/.local/lib/python3.12/site-packages/pandas/io/parsers/readers.py:1026, in read_csv(filepath_or_buffer, sep, delimiter, header, names, index_col, usecols, dtype, engine, converters, true_values, false_values, skipinitialspace, skiprows, skipfooter, nrows, na_values, keep_default_na, na_filter, verbose, skip_blank_lines, parse_dates, infer_datetime_format, keep_date_col, date_parser, date_format, dayfirst, cache_dates, iterator, chunksize, compression, thousands, decimal, lineterminator, quotechar, quoting, doublequote, escapechar, comment, encoding, encoding_errors, dialect, on_bad_lines, delim_whitespace, low_memory, memory_map, float_precision, storage_options, dtype_backend)
   1013 kwds_defaults = _refine_defaults_read(
   1014     dialect,
   1015     delimiter,
   (...)   1022     dtype_backend=dtype_backend,
   1023 )
   1024 kwds.update(kwds_defaults)
-> 1026 return _read(filepath_or_buffer, kwds)

File ~/.local/lib/python3.12/site-packages/pandas/io/parsers/readers.py:620, in _read(filepath_or_buffer, kwds)
    617 _validate_names(kwds.get("names", None))
    619 # Create the parser.
--> 620 parser = TextFileReader(filepath_or_buffer, **kwds)
    622 if chunksize or iterator:
    623     return parser

File ~/.local/lib/python3.12/site-packages/pandas/io/parsers/readers.py:1620, in TextFileReader.__init__(self, f, engine, **kwds)
   1617     self.options["has_index_names"] = kwds["has_index_names"]
   1619 self.handles: IOHandles | None = None
-> 1620 self._engine = self._make_engine(f, self.engine)

File ~/.local/lib/python3.12/site-packages/pandas/io/parsers/readers.py:1880, in TextFileReader._make_engine(self, f, engine)
   1878     if "b" not in mode:
   1879         mode += "b"
-> 1880 self.handles = get_handle(
   1881     f,
   1882     mode,
   1883     encoding=self.options.get("encoding", None),
   1884     compression=self.options.get("compression", None),
   1885     memory_map=self.options.get("memory_map", False),
   1886     is_text=is_text,
   1887     errors=self.options.get("encoding_errors", "strict"),
   1888     storage_options=self.options.get("storage_options", None),
   1889 )
   1890 assert self.handles is not None
   1891 f = self.handles.handle

File ~/.local/lib/python3.12/site-packages/pandas/io/common.py:873, in get_handle(path_or_buf, mode, encoding, compression, memory_map, is_text, errors, storage_options)
    868 elif isinstance(handle, str):
    869     # Check whether the filename is to be opened in binary mode.
    870     # Binary mode does not support 'encoding' and 'newline'.
    871     if ioargs.encoding and "b" not in ioargs.mode:
    872         # Encoding
--> 873         handle = open(
    874             handle,
    875             ioargs.mode,
    876             encoding=ioargs.encoding,
    877             errors=errors,
    878             newline="",
    879         )
    880     else:
    881         # Binary mode
    882         handle = open(handle, ioargs.mode)

FileNotFoundError: [Errno 2] No such file or directory: 'data/addresses-geoloc.csv'
# créez une map centrée sur ce point et de zoom 13
# n'oubliez pas de l'afficher,
# et vérifiez que vous voyez bien Paris, 
# que vous pouvez zoomer et vous déplacer, ...

# votre code
def paris_map():
    ...

# pour l'afficher
paris_map()

on ajoute les adresses

pareil mais vous ajoutez les adresses qui se trouvent dans la dataframe
éventuellement, vous pouvez comme sur l’exemple du Getting Started ajouter un tooltip avec l’adresse complète

# your code here

def map_addresses(geoloc):
    """
    creates folium map centered on Paris, with the various addresses
    contained in the input dataframe shown as a marker
    """
    pass
# and try it out

# make sure you use A SMALL DATAFRAME because with this method
# trying to display tens of thousands addresses
# is going to be SUPER SLOOOOOW !

map_addresses(addresses_small)

on sauve la carte

une fonction très sympatique de folium, c’est qu’on peut sauver cette carte sous la forme d’un fichier html, on dit standalone, c’est-à-dire autosuffisant, pas besoin de Python ni de Jupyter pour la regarder

map_small = map_addresses(addresses_small)
map_small.save("addresses-small.html")
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[19], line 2
      1 map_small = map_addresses(addresses_small)
----> 2 map_small.save("addresses-small.html")

AttributeError: 'NoneType' object has no attribute 'save'

et maintenant pour voir le résultat, ouvrez le fichier dans un autre tab du navigateur

les quartiers de Paris

maintenant on va ranger les adresses par quartier; pour cela nous avons dans le dossier data/ le fichier quartier_paris.zip qui contiennent la définition des 80 quartiers qui recouvrent Paris intra-muros

ça se présente comme ceci:

# à ce stade on a besoin de geopandas
# installez-le si besoin

import geopandas as gpd
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[20], line 4
      1 # à ce stade on a besoin de geopandas
      2 # installez-le si besoin
----> 4 import geopandas as gpd

ModuleNotFoundError: No module named 'geopandas'
# ça se lit facilement
quartiers = gpd.read_file("data/quartier_paris.zip", encoding="utf8")

# et le résultat est .. une dataframe
type(quartiers)
# qu'on peut afficher normalement

quartiers
# et on peut même l'afficher sommairement avec matplotlib

# bon c'est loin d'être parfait mais ça nous suffit à ce stade
# on va voir plus loin comment incorporer ça dans la carte

quartiers.plot();

pour afficher cela sur la carte, il faut se livrer à une petite gymnastique

  1. on construit la map comme on l’a vu jusqu’ici
  2. on passe à folium.GeoJson() la geo-dataframe, pour créer un objet
  3. qu’on ajoute dans la map

ce qui nous donne ceci:

def paris_map():
    """
    create a map of Paris with its 80 quartiers
    """
    map = folium.Map(
        location=CENTER,
        zoom_start=13,
        # width='80%',
    )

    # create a folium JSON object from the geo df
    folium.GeoJson(
        data=quartiers,
        # optionnally we could also tweak things on the way
        # try to uncomment these
        # style_function=lambda x: {"fillColor": "#45a012", "color": "#881212"}
        # the name is in the l_qu column
        # tooltip=folium.GeoJsonTooltip(fields=['l_qu'], aliases=['quartier']),
    # and add it to the map
    ).add_to(map)

    # and that's it
    return map
paris_map()

un peu de couleurs

maintenant c’est à vous: il s’agit d’améliorer cela pour ajouter des couleurs aux quartiers:

  1. écrivez une fonction random_color qui renvoie une couleur au hasard, i.e. une chaine comme #12f285
  2. ajoutez dans la dataframe quartiers une colonne contenant une couleur aléatoire
  3. écrivez map_addresses_in_quartiers_2, une variante qui affiche les quartiers avec chacun une couleur

couleurs: étape 1

# in order to display a number in hexadecimal, you can use this 

x = 10
f"{x:02x}"
# your code here

def random_color():
    """
    returns a random color as a string like e.g. #12f285
    that is to say containing 3 bytes in hexadecimal
    """
    # of course this is not the right answer
    return "#12f285"
random_color(), random_color()

couleurs: étape 2

# add a random color column in quartiers

# your code here
quartiers.head(3)

couleurs: étape 3

# display the quartiers with their individual color

# your code

def paris_map():
    """
    create a map of Paris with its 80 quartiers
    each quartier is shown in its individual color
    """
    pass
# display it
paris_map()

spatial join

ce qu’on aimerait bien faire à présent, c’est de trouver le quartier de chaque adresse
c’est-à-dire en pratique de rajouter dans la dataframe des adresses une ou des colonnes indiquant le quartier

et pour faire cela il existe un outil très intéressant dans geopandas qui s’appelle le spatial join et qui est décrit ici

en deux mots, l’idée c’est de faire comme un JOIN usuel (ou un pd.merge() si vous préférez)
mais pour décider si on doit aligner deux lignes (une dans la df de gauche et l’autre dans la df de droite):

ce qui signifie, en pratique, qu’on va faire ceci

  1. transformer la dataframe d’adresses en une GeoDataFrame et remplacer les colonnes latitude et longitude par une colonne position, cette fois dans un format connu de geopandas
  2. et ainsi on pourra appliquer un spatial join entre la (géo)dataframe d’adresses et la (géo)dataframe des quartiers, en choisissant ce prédicat contains
  3. et du coup modifier la carte pour afficher les adresses dans la bonne couleur - celle du quartier - pour vérifier qu’on a bien fait correctement le classement en quartiers

on doit donc obtenir autant de ligne que d’adresses, mais avec une ou des colonnes en plus (couleur, nom du quartier, ...) qui caractérisent le quartier dans lequel se trouve l’adresse

spatial join: étape 1

je vous donne le code; ce qu’il faut savoir notamment c’est qu’en geopandas il y a la notion de ‘colonne active’, celle qui contient les informations géographiques; ça n’est pas forcément hyper-intuitif la première fois...

https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoDataFrame.set_geometry.html

def convert_lat_lon(df):
    """
    the input df is expected to have 2 columns named 'latitude' and 'longitude'

    this function will create a GeoDataFrame based on that, where 'latitude' and 'longitude'
    are replaced by a new column named 'position' 
    also this new column becomes the active geometry for future operations 
    (in our case the spatial join)
    """

    # we need a geopandas-friendly df
    geo_df = gpd.GeoDataFrame(df)

    # beware, here LONGITUDE comes first !
    geo_df['position'] = gpd.points_from_xy(df.longitude, df.latitude)

    # it would make sense to clean up
    # BUT
    # our map-production code relies on these columns, so...
    # del geo_df['latitude']
    # del geo_df['longitude']

    # declare the new column as the active one
    geo_df.set_geometry('position', inplace=True)

    return geo_df
# let's apply that to our small input

geoaddresses_small = convert_lat_lon(addresses_small)
# we will also need to set the active column in the quartiers (geo)dataframe

quartiers.set_geometry('geometry', inplace=True)

spatial join: étape 2

Il ne nous reste plus qu’à faire ce fameux spatial join, je vous laisse trouver le code pour faire ça

# spatial join allows to extend the addresses dataframe
# with quartier / arrondissement information

# your code

def add_quartiers(gdf):
    """
    given an addresses geo-dataframe, 
    (i.e. typically produced as an output of convert_lat_lon)
    this function will use spatial join with the quartiers information
    and return a copy of gdf extended with columns such as
    l_qu : name of the quartier
    c_ar : arrondissement number
    color: the (random) color of that quartier
    ...

    """
    # this is not the right answer...
    return gdf
# try your code

# xxx you can safely ignore this warning...
# UserWarning: CRS mismatch between the CRS of left geometries and the CRS of right geometries

geoaddresses_small_extended = add_quartiers(geoaddresses_small)
geoaddresses_small_extended.head(2)
# verify your code

# make sure you have the right number of lines in the result

geoaddresses_small_extended.shape

spatial join: étape 3

on va donc maintenant récrire map_addresses; la logique reste la même mais on veut afficher chaque adresse avec une couleur qui provient de son quartier

comme vous allez le voir, l’objet folium.Marker ne peut pas s’afficher avec une couleur arbitraire - il semble qu’il y ait seulement une petite liste de couleurs supportées

pour contourner ça, utilisez à la place un objet de type folium.CircleMarker

# rewrite map_addresses so that each address is shown 
# in the color of its quartier

def map_addresses(gdf):
    """
    (slightly) rewrite the first version of this function

    your input is now a geopandas dataframe, with the information
    about the 'quartier'

    and your job is to display all the addresses on the map, now with
    the color of the 'quartier'

    return a folium map of paris with the adresses displayed
    """
    return
# test the new function

map = map_addresses(geoaddresses_small_extended)

map

on sauve la carte

c’est sans doute un bon moment pour sauver tout ce qu’on a fait:

# votre code

on clusterise

pour pouvoir passer à l’échelle, il est indispensable de clusteriser; c’est-à-dire de grouper les points en fonction du niveau de zoom; voyons ce que ça donne

ne perdez pas de temps à chercher le code vous-même, c’est un peu hacky comme on dit, voici comment il faut faire

from folium import plugins

def map_addresses(gdf):

    # start like before
    if 'human' not in gdf.columns:
        # create a column with a human-readable address
        gdf['human'] = gdf['number'].astype(str) + ', ' + gdf['type'] + ' ' + gdf['name']

    map = paris_map()


    # we need a JavaScript function...
    # this is required to get  good performances
    callback = """
function (row) {
  // if you need to debug it
  // console.log(row)
  let circle = L.circleMarker(
      // the position
      new L.LatLng(row[0], row[1]),
      // styling
     {color: row[3], radius: 8},
    )
  // add the tooltip
  circle.bindTooltip(row[2]).openTooltip()
  return circle
}
"""

    # compute an extract with fewer columns
    # not strictly necessary but easier to deal with column names
    extract = gdf[['latitude', 'longitude', 'human', 'color']]
    cluster = plugins.FastMarkerCluster(
        extract, callback=callback,
        # nicer behaviour
        disableClusteringAtZoom=18,
    ).add_to(map)

    return map

sur l’échantillon

# let's first test it on the small extract

map_addresses(geoaddresses_small_extended)

sur le dataset entier

# and if all goes well we can try and display the full monty

# first prepare the full dataset

geoaddresses = add_quartiers(convert_lat_lon(addresses))
final_map = map_addresses(geoaddresses)

final_map
# makes sense to save the hard work

geoaddresses.to_csv("addresses-final.csv")
final_map.save("addresses-final.html")

références

le jeu de données utilisé ici provient à l’origine de Brando et al. (2024), légèrement retravaillé pour les besoins du TP


References
  1. Brando, C., Elgarrista, G., Lapatniova, A., Mélanie-Becquet, F., & Parmentelat, A. (2024). Vol 1898 - index adresses - Annuaire des propriétaires et des propriétés de Paris et du département de la Seine. NAKALA - https://nakala.fr (Huma-Num - CNRS). 10.34847/NKL.3038F62V