Analyse de l'épidémie de choléra à Londres en 1854

Contexte historique

En 1854, le quartier de Soho à Londres a vécu une des pires épidémies de choléra du Royaume-Uni, avec 616 morts. Cette épidémie est devenue célèbre à cause de l'analyse détaillée de ses causes réalisée par le médecin John Snow. Ce dernier a notamment montré que le choléra est transmis par l'eau plutôt que par l'air, ce qui était la théorie dominante de l'époque.

Analyse reproductible

Récupération des données numériques

Tout d'abord récupérons les données numériques mis à notre disposition sur ce blog. Nous pouvons retrouver sur ce site deux archives SnowGIS_SHP.zip et SnowGIS_KML.zip contenant des formats de fichiers différents. Le site mettait aussi à disposition des liens google où les données étaient directement accessibles mais malheureusement ces liens ne sont plus fonctionnels. Nous utiliserons donc les données d'une des archives téléchargées et importées afin de garantir la pérennité et l'accessibilité à ces données.

Nous avons 4 informations à notre disposition dans ces archives :

  • Emplacements des décès dus au choléra (vecteur) avec le nombre de décès à chaque point
  • Emplacements des pompes (vecteur)
  • La carte originale de John Snow géoréférencée sur la grille nationale de l'Ordnance Survey (raster)
  • Cartes actuelles de l'Ordnance Survey de la zone (à partir de celles publiées sous OS OpenData; Contient les données de l'Ordnance Survey © Copyright et droit de la base de données 2013; Raster)

Étant donné que nous utiliserons la bibliothèque folium pour l'affichage de la carte, seules les deux premières nous seront utiles. Si la bibliothèque n'est pas présente dans l'environnement, il faudra l'installer avec la commande pip install folium.

La première archive SnowGIS_SHP contient des fichiers SHP (Shapefiles) qui sont interpretable par la bibliothèque geopandas. Nous ne nous sera pas nécessaire d'utiliser l'autre archive avec les fichiers KML.

Nous utiliserons la bibliothèque geopandas pour l'importation des données au format .shp. Si la bibliothèque geopandas n'est pas présente dans l'environnement, il faudra l'installer avec la commande pip install geopandas.

In [1]:
pip install geopandas
Requirement already satisfied: geopandas in /opt/conda/lib/python3.6/site-packages (0.8.1)
Requirement already satisfied: pandas>=0.23.0 in /opt/conda/lib/python3.6/site-packages (from geopandas) (1.1.1)
Requirement already satisfied: pyproj>=2.2.0 in /opt/conda/lib/python3.6/site-packages (from geopandas) (2.6.1.post1)
Requirement already satisfied: fiona in /opt/conda/lib/python3.6/site-packages (from geopandas) (1.8.13.post1)
Requirement already satisfied: shapely in /opt/conda/lib/python3.6/site-packages (from geopandas) (1.7.1)
Requirement already satisfied: python-dateutil>=2.7.3 in /opt/conda/lib/python3.6/site-packages (from pandas>=0.23.0->geopandas) (2.8.1)
Requirement already satisfied: numpy>=1.15.4 in /opt/conda/lib/python3.6/site-packages (from pandas>=0.23.0->geopandas) (1.19.1)
Requirement already satisfied: pytz>=2017.2 in /opt/conda/lib/python3.6/site-packages (from pandas>=0.23.0->geopandas) (2019.3)
Requirement already satisfied: cligj>=0.5 in /opt/conda/lib/python3.6/site-packages (from fiona->geopandas) (0.5.0)
Requirement already satisfied: click-plugins>=1.0 in /opt/conda/lib/python3.6/site-packages (from fiona->geopandas) (1.1.1)
Requirement already satisfied: click<8,>=4.0 in /opt/conda/lib/python3.6/site-packages (from fiona->geopandas) (7.1.2)
Requirement already satisfied: six>=1.7 in /opt/conda/lib/python3.6/site-packages (from fiona->geopandas) (1.14.0)
Requirement already satisfied: munch in /opt/conda/lib/python3.6/site-packages (from fiona->geopandas) (2.5.0)
Requirement already satisfied: attrs>=17 in /opt/conda/lib/python3.6/site-packages (from fiona->geopandas) (19.3.0)
Note: you may need to restart the kernel to use updated packages.
In [2]:
import geopandas as gpd
death_cholera_location = gpd.read_file("Cholera_Deaths.shp", crs={"init": "epsg:4326"})
pumps_location = gpd.read_file("Pumps.shp", crs={"init": "epsg:4326"})

Visualisation et traitement des données

Visualisons les données que nous avons concernant les décès dus au choléra. Comme il était indiqué, nous avons le nombre (count) et la localisation (geometry) des décès.

In [3]:
death_cholera_location
Out[3]:
Id Count geometry
0 0 3 POINT (529308.741 181031.352)
1 0 2 POINT (529312.164 181025.172)
2 0 1 POINT (529314.382 181020.294)
3 0 1 POINT (529317.380 181014.259)
4 0 4 POINT (529320.675 181007.872)
... ... ... ...
245 0 3 POINT (529362.665 181156.058)
246 0 2 POINT (529365.152 181176.129)
247 0 1 POINT (529274.165 180907.313)
248 0 1 POINT (529299.361 180873.185)
249 0 1 POINT (529324.815 180857.949)

250 rows × 3 columns

Vérifions d'abord s'il n'y a pas de données manquantes.

In [4]:
death_cholera_location.isna().any().any()
Out[4]:
False

Bonne nouvelle, il n'y a pas de données manquantes.

Nous pouvons d'ors et déjà remarquer que les points sont des coordonnées X/Y et non latitude/longitude car les valeurs de celles-ci doivent être comprises entre +90°/-90° et +180°/-180°. Or la bibliothèque folium prend en compte ces valeurs pour le positionnement de points. Il sera donc nécessaire d'effectuer une conversion.

In [5]:
death_cholera_location = death_cholera_location.to_crs(epsg=4326) #Conversion au bon format

Regardons si la conversion a bien été faites.

In [6]:
death_cholera_location
Out[6]:
Id Count geometry
0 0 3 POINT (-0.13793 51.51342)
1 0 2 POINT (-0.13788 51.51336)
2 0 1 POINT (-0.13785 51.51332)
3 0 1 POINT (-0.13781 51.51326)
4 0 4 POINT (-0.13777 51.51320)
... ... ... ...
245 0 3 POINT (-0.13711 51.51453)
246 0 2 POINT (-0.13706 51.51471)
247 0 1 POINT (-0.13847 51.51231)
248 0 1 POINT (-0.13812 51.51200)
249 0 1 POINT (-0.13776 51.51186)

250 rows × 3 columns

Les coordonnées ressemblent déjà plus à ce que l'on recherche. Faisons une vérification rapide sur la carte de la localisation avec les premières coordonnées.

In [7]:
pip install folium
Requirement already satisfied: folium in /opt/conda/lib/python3.6/site-packages (0.11.0)
Requirement already satisfied: requests in /opt/conda/lib/python3.6/site-packages (from folium) (2.23.0)
Requirement already satisfied: branca>=0.3.0 in /opt/conda/lib/python3.6/site-packages (from folium) (0.4.1)
Requirement already satisfied: jinja2>=2.9 in /opt/conda/lib/python3.6/site-packages (from folium) (2.11.0)
Requirement already satisfied: numpy in /opt/conda/lib/python3.6/site-packages (from folium) (1.19.1)
Requirement already satisfied: urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1 in /opt/conda/lib/python3.6/site-packages (from requests->folium) (1.25.7)
Requirement already satisfied: idna<3,>=2.5 in /opt/conda/lib/python3.6/site-packages (from requests->folium) (2.9)
Requirement already satisfied: chardet<4,>=3.0.2 in /opt/conda/lib/python3.6/site-packages (from requests->folium) (3.0.4)
Requirement already satisfied: certifi>=2017.4.17 in /opt/conda/lib/python3.6/site-packages (from requests->folium) (2020.4.5.1)
Requirement already satisfied: MarkupSafe>=0.23 in /opt/conda/lib/python3.6/site-packages (from jinja2>=2.9->folium) (1.1.1)
Note: you may need to restart the kernel to use updated packages.
In [8]:
import folium as fl
m = fl.Map(location=[-0.13667, 51.51334],zoom_start=5)
m
Out[8]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Il semblerait que les coordonnées soient mauvaises. Après vérification sur la documentation de geopandas, POINT = (Longitude, Latitude). Et pour folium, nous rentrons location=[Latitude, Longitude].

Essayons d'inverser les valeurs et revérifions.

In [9]:
m = fl.Map(location=[51.51342, -0.13793],zoom_start=10)
m
Out[9]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Nous sommes bien à Londre, les coordonnées semblent donc être correct.

Maintenant que nous nous sommes assuré de la conformité des données, réitérons la même chose pour la liste des pompes :

In [10]:
pumps_location = pumps_location.to_crs(epsg=4326) #Conversion au bon format
pumps_location
Out[10]:
Id geometry
0 0 POINT (-0.13667 51.51334)
1 0 POINT (-0.13959 51.51388)
2 0 POINT (-0.13967 51.51491)
3 0 POINT (-0.13163 51.51235)
4 0 POINT (-0.13359 51.51214)
5 0 POINT (-0.13592 51.51154)
6 0 POINT (-0.13396 51.51002)
7 0 POINT (-0.13820 51.51130)

Représentation des données sur la carte

Essayons maintenant d'afficher des cercles de circonférence proportionnelle au nombre du décès et des symboles pour la localisation des pompes.

Reprenons notre carte précédente avec un zoom plus important.

In [11]:
m = fl.Map(location=[51.51334, -0.13667],zoom_start=17)
for index, death_row in death_cholera_location.iterrows():
    death_count = death_row['Count']
    point_geometry = death_cholera_location.geometry[index]
    fl.Circle(
    radius=death_count,
    location=[point_geometry.centroid.y,point_geometry.centroid.x], #Ne pas oublier que longitude et latitude sont inversées.
    tooltip=death_count,
    color='crimson',
    fill=True,
    ).add_to(m)
m
Out[11]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Voilà pour la localisation des décès, le radius du cercle correspondant au nombre de décès.

Ajoutons maintenant les pompes.

In [12]:
for index, pump_row in pumps_location.iterrows():
    point_geometry = pumps_location.geometry[index]
    fl.Marker([point_geometry.centroid.y,point_geometry.centroid.x], popup='<b>Pompes '+str(index)+'</b>').add_to(m) #Ne pas oublier que longitude et latitude sont inversées.
m
Out[12]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Nous pouvons en effet remarquer qu'une pompe se trouve au centre de ces décès et proche d'une zone ayant eu un grand nombre de décès. Il s'agit de la pompe se trouvant dans la rue Broadwick Street. Il s'agit de la pompe 0, soit la première de la liste fournit.

Une autre approche autour de la pompe de Broadwick Street

Nombre de décès suivant la distance des pompes

Enfin de mettre en évidence la pompe de Broadwick Street comme ayant eu un impact dans l'épidémie de choléra, nous pouvons également comparer le nombre de décès autour de chacune des pompes dans un rayon définit. Pour cela, nous utiliserons la librairie haversine pour le calcul de distance entre deux coordonnées.

In [13]:
pip install haversine
Requirement already satisfied: haversine in /opt/conda/lib/python3.6/site-packages (2.2.0)
Note: you may need to restart the kernel to use updated packages.

Enfin de délimiter et de définir une taille de rayon commune autour de chacune des pompes, calculons la distance moyenne des pompes entre elles.

In [14]:
import haversine as hs
from haversine import Unit
import numpy as np

list_range_mean = []
for index_pump, pump_row in pumps_location.iterrows():
    pump_point_geometry = pumps_location.geometry[index_pump]
    list_relative_distances = []
    # Pour chaque pompe, on calcule la distance relative avec les autres pompes.
    for index, pump_row in pumps_location.iterrows():
        if index != index_pump: # On oublie pas d'exclure la pompe de référence pour le calcule.
            death_point_geometry = pumps_location.geometry[index]
            distance = hs.haversine((death_point_geometry.centroid.y,death_point_geometry.centroid.x),(pump_point_geometry.centroid.y,pump_point_geometry.centroid.x), unit=Unit.METERS)
            list_relative_distances.append(distance)
    # On fait la moyenne des distance pour chaque pompes.
    list_range_mean.append(np.mean(list_relative_distances))
# Il suffit ensuite de calculer la moyenne de l'ensemble.
max_range = np.mean(list_range_mean)
# On affiche le résultat 
max_range
Out[14]:
347.1091983707074

Les pompes sont donc, en moyenne, à 347 mètres l'une de l'autre.

Calculons le nombre de décès se trouvant dans un rayon de 347 mètres autour de chaque pompe.

In [15]:
import pandas as pd
df_pump_death = pd.DataFrame(columns=['Pompe Index', 'Latitude', 'Longitude', 'Nombre de décès dans un rayon de 300m'])
for index_pump, pump_row in pumps_location.iterrows():
    death_count = 0
    pump_point_geometry = pumps_location.geometry[index_pump]
    for index, death_row in death_cholera_location.iterrows():
        death_point_geometry = death_cholera_location.geometry[index]
        distance = hs.haversine((death_point_geometry.centroid.y,death_point_geometry.centroid.x),(pump_point_geometry.centroid.y,pump_point_geometry.centroid.x), unit=Unit.METERS)
        if distance <= max_range:
            death_count += death_row['Count']
    
    df_pump_death = df_pump_death.append({'Pompe Index': index_pump, 'Latitude': pump_point_geometry.centroid.y, 'Longitude': pump_point_geometry.centroid.x, 'Nombre de décès dans un rayon de 300m': death_count}, ignore_index=True)
df_pump_death
Out[15]:
Pompe Index Latitude Longitude Nombre de décès dans un rayon de 300m
0 0.0 51.513341 -0.136668 489.0
1 1.0 51.513876 -0.139586 404.0
2 2.0 51.514906 -0.139671 339.0
3 3.0 51.512354 -0.131630 227.0
4 4.0 51.512139 -0.133594 404.0
5 5.0 51.511542 -0.135919 453.0
6 6.0 51.510019 -0.133962 81.0
7 7.0 51.511295 -0.138199 381.0

On remarque tout de suite que la pompe avec le plus de décès dans un rayon moyen de 347 mètres est la pompe à l'index 0, qui correspond à la pompe de Broadwick Street.

Visualisons le résultat sur la carte en modifiant l'opacité suivant le nombre de décès.

In [17]:
m = fl.Map(location=[51.51334, -0.13667],zoom_start=15)
max_dead = np.max(df_pump_death['Nombre de décès dans un rayon de 300m'])
for index, pump_row in df_pump_death.iterrows():
    fl.Circle(
    radius=max_range,
    stroke=True,
    weight=5,
    location=[pump_row['Latitude'],pump_row['Longitude']], #Ne pas oublier que longitude et latitude sont inversées.
    tooltip=pump_row['Nombre de décès dans un rayon de 300m'],
    color='crimson',
    opacity=pump_row['Nombre de décès dans un rayon de 300m']/max_dead,
    fillOpacity=pump_row['Nombre de décès dans un rayon de 300m']/max_dead,
    fill=True,
    ).add_to(m)
    fl.Marker([pump_row['Latitude'],pump_row['Longitude']], popup='<b>Pompes '+str(index)+'</b>').add_to(m) #Ne pas oublier que longitude et latitude sont inversées.
m
Out[17]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Et affichons uniquement le rayon de la pompe de Broadwick Street ainsi que l'emplacement des décès.

In [19]:
m = fl.Map(location=[51.51334, -0.13667],zoom_start=16)
# Emplacement des décès
for index, death_row in death_cholera_location.iterrows():
    death_count = death_row['Count']
    point_geometry = death_cholera_location.geometry[index]
    fl.Circle(
    radius=death_count,
    location=[point_geometry.centroid.y,point_geometry.centroid.x], #Ne pas oublier que longitude et latitude sont inversées.
    tooltip=death_count,
    color='crimson',
    fill=True,
    ).add_to(m)
# Pompe de Broadwick Street
fl.Circle(
    radius=max_range,
    stroke=True,
    weight=5,
    location=[df_pump_death.values[0][1],df_pump_death.values[0][2]], 
    tooltip=df_pump_death.values[0][3],
    color='orange',
    opacity=df_pump_death.values[0][3]/max_dead,
    fillOpacity=df_pump_death.values[0][3]/max_dead,
    fill=True,
    ).add_to(m)
fl.Marker([df_pump_death.values[0][1],df_pump_death.values[0][2]], popup='<b>Pompes de Broadwick Street</b>').add_to(m) 
m
Out[19]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Nous pouvons visualiser que la pompe de Broadwick est belle et bien au centre de cette épidémie.

Bonus : Affichage des décès en Heatmap

In [20]:
from folium.plugins import HeatMap

df_death = pd.DataFrame(columns=['Index', 'Latitude', 'Longitude', 'Nb décès'])
for index, death_row in death_cholera_location.iterrows():
    point_geometry = death_cholera_location.geometry[index]
    df_death = df_death.append({'Index': index, 'Latitude': point_geometry.centroid.y, 'Longitude': point_geometry.centroid.x, 'Nb décès': death_row['Count']}, ignore_index=True)


m = fl.Map(location=[51.51334, -0.13667],zoom_start=16)
HeatMap(data=df_death[['Latitude', 'Longitude', 'Nb décès']].groupby(['Latitude', 'Longitude']).sum().reset_index().values.tolist(), radius=20).add_to(m)
fl.Marker([51.51334,-0.13667], popup='<b>Pompes de </b>').add_to(m) #Ne pas oublier que longitude et latitude sont inversées.
m
Out[20]:
Make this Notebook Trusted to load map: File -> Trust Notebook
In [ ]: