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://
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
? et & dans l’URL
dans une autre dimension complètement: ici on envoie donc une requête vers l’URLhttps://api-adresse.data.gouv.fr/search/?q=18+rue+BERNARDINS,Paris&limit=1
Les caractères ?
et &
jouent un rôle particulier: pour information, la syntaxe générale c’est
http://le.host.name/le/path?param1=truc¶m2=bidule¶m3=machinechose
et donc de cette façon, c’est un peu comme si on appelait une fonction à distance, en lui passant
q=18+rue+BERNARDINS,Paris
(q
pour query)- et
limit=1
(pour avoir seulement la première réponse)
et pour vous faire réfléchir: il se passerait quoi si par exemple dans la colonne name
il y avait un caractère &
(imaginez la rue Bouvart & Ratinet)
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 ?
curl
est un programme qu’on peut utiliser directement dans le terminal pour faire des requêtes httpdans son utilisation la plus simple, il permet par exemple d’aller chercher une page web: vous copiez l’URL depuis le navigateur, et vous la donnez à
curl
, qui peut ranger la page dans un fichiercurl -o lapageweb.html http://github.com/flotpython/
quand on utilise une API, comme on vient de le faire pour aller chercher la position de la rue des bernardins, on doit passer des paramètres à l’API; pour faire ça dans une requête http, il y a deux mécanismes: GET et POST
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
data
désigne le nom d’un fichier csv qui contient les données à géolocaliser, une par lignecolumns
désigne les noms des colonnes qui contiennent l’adresse
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
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
le résultat - toujours au format csv - pourra être également transformé en dataframe
qu’il ne restera plus qu’à
merge
(oujoin
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 courseresult_city
pour pouvoir vérifier la validité des résultats - ici on devrait toujours avoirParis
result_type
qui devrait toujours renvoyerhousenumber
, ça permet à nouveau de vérifier qu’on a bien une adresse connue
pour envoyer un POST avec des paramètres, on peut faire
response = requests.post(url, file=some_dict, data=another_dict)
et donc dans notre cas, puisque
data
est un paramètre de type fichier, alors quecolumns
est un paramètre usuel, on feraresponse = requests.post(url, file={'data': filename}, data={'columns': ['col1', ...]})
enfin,
pd.read_csv
s’attend à un paramètre de type fichier, i.e. du genre de ce qu’on obtient avecopen()
et du coup pour reconstruire une dataframe à partir du texte obtenu dans la requête http, on a deux choix- soit on commence par sauver le texte dans un fichier temporaire (juste faire attention à choisir un nom de fichier qui n’existe pas, de préférence dans un dossier temporaire, voir le module
tempfile
) - soit on triche un peu, et grâce à
io.StringIO
on peut transformer une chaine en fichier !
c’est ce qu’on va faire dans notre solution, mais la première option est tout à fait raisonnable aussi
- soit on commence par sauver le texte dans un fichier temporaire (juste faire attention à choisir un nom de fichier qui n’existe pas, de préférence dans un dossier temporaire, voir le module
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):
- l’objet
Map
https://python -visualization .github .io /folium /quickstart .html #Getting -Started - l’objet
Marker
https://python -visualization .github .io /folium /quickstart .html #Markers - et un peu plus tard on utilisera aussi des overlays https://
python -visualization .github .io /folium /quickstart .html #GeoJSON /TopoJSON -Overlays
# ~ 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
source
obtenu ici https://
en choisissant le format “Shapefile”
ç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
- on construit la map comme on l’a vu jusqu’ici
- on passe à
folium.GeoJson()
la geo-dataframe, pour créer un objet - 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:
- écrivez une fonction
random_color
qui renvoie une couleur au hasard, i.e. une chaine comme#12f285
- ajoutez dans la dataframe
quartiers
une colonne contenant une couleur aléatoire - é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¶
ça m’a pris un peu de temps à trouver comment faire, voici un indice
# 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):
- au lieu de vérifier l’égalité entre deux colonnes
- on va cette fois utiliser un prédicat entre deux colonnes géographiques, comme par exemple ici le prédicat
contains
ce qui signifie, en pratique, qu’on va faire ceci
- transformer la dataframe d’adresses en une
GeoDataFrame
et remplacer les colonneslatitude
etlongitude
par une colonneposition
, cette fois dans un format connu degeopandas
- 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
- 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://
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:
- sauvez la geo-dataframe dans un fichier
addresses-small-extended.csv
- sauvez la carte au format html (même nom sinon) pour une utilisation en standalone (par exemple pour la publier sur un site web indépendant des notebooks et de jupyter et tout ça)
# 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
- 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