geopandas
¶%matplotlib inline
import matplotlib.pyplot as plt
import geopandas as gpd
import pysal as ps
from pysal.contrib.viz import mapping as maps
In this lab, we will learn how to load, manipulate and visualize spatial data. In some senses, spatial data have become so pervasive that nowadays, they are usually included simply as "one more column" in a table. However, spatial is special and there are few aspects in which geographic data differ from standard numerical tables. In this session, we will extend the skills developed in the previous one about non-spatial data, and combine them. In the process, we will discover that, although with some particularities, dealing with spatial data in Python largely resembles dealing with non-spatial data. For example, in this lab you will learn to make slick maps like this one with just a few commands:
Or interactive ones like this one where the smallest areas of Liverpool are highlighted in red:
# Please note this will not display on your machine as it depends on an external file
# A rendered version can be found on the HTML or PDF versions of this notebook
from IPython.display import IFrame
IFrame("figs/lab03_liverpool_smallest.html", width=560, height=315)
To learn these concepts, we will be playing again with the geography of Liverpool. In particular we will use Census geographies (Available as part of the Census Data pack used before, see link) and Ordnance Survey geospatial data, available to download also from the CDRC data store (link). To make the rest of the notebook easier to follow, let us set the paths to the main two folders here. We will call the path to the Liverpool Census pack lcp_dir
, and that to the OS geodata los_dir
:
lcp_dir = '../../../../data/Liverpool/'
los_dir = '../../../../data/E08000012_OS/'
The easiest way to get from a file to a quick visualization of the data is by loading it as a GeoDataFrame
and calling the plot
command. The main library employed for all of this is geopandas
which is a geospatial extension of the pandas
library, already introduced before. geopandas
supports exactly the same functionality that pandas
does (in fact since it is built on top of it, so most of the underlying machinery is pure pandas
), plus a wide range of spatial counterparts that make manipulation and general "munging" of spatial data as easy as non-spatial tables.
In two lines of code, we will obtain a graphical representation of the spatial data contained in a file that can be in many formats; actually, since it uses the same drivers under the hood, you can load pretty much the same kind of vector files that QGIS permits. Let us start by plotting single layers in a crude but quick form, and we will build style and sophistication into our plots later on.
Let us begin with the most common type of spatial data in the social science: polygons. For example, we can load the geography of LSOAs in Liverpool with the following lines of code:
lsoas_link = lcp_dir + 'shapefiles/Liverpool_lsoa11.shp'
lsoas = gpd.read_file(lsoas_link)
Now lsoas
is a GeoDataFrame
. Very similar to a traditional, non-spatial DataFrame
, but with an additional column called geometry
:
lsoas.head()
This allows us to quickly produce a plot by executing the following line:
lsoas.plot()
This might not be the most aesthetically pleasant visual representation of the LSOAs geography, but it is hard to argue it is not quick to produce. We will work on styling and customizing spatial plots later on.
Pro-tip: if you call a single row of the geometry
column, it'll return a small plot ith the shape:
lsoas.loc[0, 'geometry']
Displaying lines is as straight-forward as polygons. To load railway tunnels in Liverpool and name the rows after the id
column (or to "index" them):
rwy_tun = gpd.read_file(los_dir + 'RailwayTunnel.shp')
rwy_tun = rwy_tun.set_index('id')
rwy_tun.info()
Note how, similarly to the polygon case, if we pick the "geometry"
column of a table with lines, a single row will display the geometry as well:
rwy_tun.loc['0ACD196C313D4F8DE050A00A568A6F6F', 'geometry']
Note how we have also indexed the table on the id
column.
A quick plot is similarly generated by (mind that because there are over 18,000 segments, this may take a little bit):
rwy_tun.plot()
Again, this is not the prettiest way to display the roads maybe, and you might want to change a few parameters such as colors, etc. All of this is possible, as we will see below, but this gives us an easy check of what lines look like.
[Optional exercise]
Obtain the graphical representation of the line with id
= 0ACD196C32214F8DE050A00A568A6F6F
.
Finally, points follow a similar structure. If we want to represent named places in Liverpool:
namp = gpd.read_file(los_dir + 'NamedPlace.shp')
namp.head()
And the plot is produced by running:
namp.plot()
It is possible to tweak several aspects of a plot to customize if to particular needs. In this section, we will explore some of the basic elements that will allow us to obtain more compelling maps.
NOTE: some of these variations are very straightforward while others are more intricate and require tinkering with the internal parts of a plot. They are not necessarily organized by increasing level of complexity.
The intensity of color of a polygon can be easily changed through the alpha
attribute in plot. This is specified as a value betwee zero and one, where the former is entirely transparent while the latter is the fully opaque (maximum intensity):
lsoas.plot(alpha=0.1)
lsoas.plot(alpha=1)
Although in some cases, the axes can be useful to obtain context, most of the times maps look and feel better without them. Removing the axes involves wrapping the plot into a figure, which takes a few more lines of aparently useless code but that, in time, it will allow you to tweak the map further and to create much more flexible designs:
f, ax = plt.subplots(1)
ax = lsoas.plot(axes=ax)
ax.set_axis_off()
plt.show()
Let us stop for a second a study each of the previous lines:
f
with one axis named ax
by using the command plt.subplots
(part of the library matplotlib
, which we have imported at the top of the notebook). Note how the method is returning two elements and we can assign each of them to objects with different name (f
and ax
) by simply listing them at the front of the line, separated by commas.ax
. This method returns the axis with the geographies in them, so we make sure to store it on an object with the same name, ax
.plt.show()
.Adding a title is a simple extra line, if we are creating the plot within a figure, as we just did. To include text on top of the figure:
f, ax = plt.subplots(1)
ax = lsoas.plot(axes=ax)
f.suptitle('Liverpool LSOAs')
plt.show()
The size of the plot is changed equally easily in this context. The only difference is that it is specified when we create the figure with the argument figsize
. The first number represents the width, the X axis, and the second corresponds with the height, the Y axis.
f, ax = plt.subplots(1, figsize=(12, 12))
ax = lsoas.plot(axes=ax)
plt.show()
You will notice that the ability to change the size of the figure is very powerful as it makes possible to obtain many different sizes and shapes for plots. However, this also may introduce some distortions in the way the shapes are represented. For example, a very wide figure can make the viewer think that polygons are in reality more "stretched out" than they are in reality:
f, ax = plt.subplots(1, figsize=(12, 4))
ax = lsoas.plot(axes=ax)
plt.show()
Although in some contexts this may be desirable (or at least, accepted), in many it will not. From a cartographic point of view, maps need to be as good representatios of reality as they can. We can ensure the scaling ratio between both axes remains fixed, whichever the shape of the figure. To do this, we only need to add a single extra line of code: plt.axis("equal")
.
f, ax = plt.subplots(1, figsize=(12, 4))
ax = lsoas.plot(axes=ax)
lims = plt.axis('equal')
plt.show()
Border lines sometimes can distort or impede proper interpretation of a map. In those cases, it is useful to know how they can be modified. Although not too complicated, the way to access borders in geopandas
is not as straightforward as it is the case for other aspects of the map, such as size or frame. Let us first see the code to make the lines thinner and grey, and then we will work our way through the different steps:
f, ax = plt.subplots(1)
for poly in lsoas['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, linewidth=0.1, edgecolor='grey')
plt.show()
Note how the lines are much thinner and discreet. In addition, because of the way we plot the data, all the polygons are colored in the same (default) color, light red.
Let us examine line by line what we are doing in the code snippet:
f
) object and one axis inside it (ax
) where we will plot the map.plot
on the DataFrame
, we loop over every element of the column geometry
. As we know from above, this dataset contains polygons representing areas of Liverpool, represented geometrically in polygons. For convenience, we call them in the loop poly
.plot_multipolygon
, which in part of the plotting
submodule in geopandas
(which, recall, we have renamed gpd
for convenience). The way this function works is you need to always pass the axis (ax
in this case) where the geometry will be drawn, then the actual geometry (poly
here), and then you can optionally pass some arguments that tweak the default appearance. Accessing each polygon at once affords us further flexibility and allows us to set the width of the border lines (linewidth
) as well as their colors (edgecolor
). Note that, because we are plotting at the polygon level, instead of at the DataFrame
level, each polygon is colored in the same way.plt.show()
.Note that we are calling the fuction plot_multipolygon
. This is so because we know we want to plot polygons. If instead we wanted to display lines, as those we read into rwy_tun
, we would take a similar approach, but using plot_multilinestring
:
f, ax = plt.subplots(1)
for line in rwy_tun['geometry']:
gpd.plotting.plot_multilinestring(ax, line, linewidth=2, color='red')
plt.show()
Important, note that in the case of lines the parameter to control the color is simply color
. This is because lines do not have an area, so there is no need to distinguish between the main area (facecolor
) and the border lines (edgecolor
).
If you understand the logic behind plotting elements individually, as shown above, modifying the color of the polygon itself is very straightforward, you only need to tweak the parameter facecolor
to your preferred color. For instance, let us draw the borders without coloring the polygons, leaving them white:
f, ax = plt.subplots(1)
for poly in lsoas['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, facecolor='white')
plt.show()
The coordindate reference system (CRS) is the way geographers and cartographers have to represent a three-dimentional object, such as the round earth, on a two-dimensional plane, such as a piece of paper or a computer screen. If the source data contain information on the CRS of the data, it is very easy to modify this in a GeoDataFrame
. First let us check if we have the information stored properly:
lsoas.crs
As we can see, there is information stored about the reference system: it is using the datum "OSGB36", which is a projection in meters (m
in units). There are also other less decipherable parameters but we do not need to worry about them right now.
If we want to modify this and "reproject" the polygons into a different CRS, the easiest way is to find the EPSG code online (epsg.io is a good one, although there are others too). For example, if we wanted to transform the dataset into lat/lon coordinates, we would use its EPSG code, 4326:
lsoas.to_crs(epsg=4326).plot()
lims = plt.axis('equal')
Because the area we are visualizing is not very large, the shape of the polygons is roughly the same. However, note how the scale in which they are plotted differs: while before we had coordinate points ranging 332,000 to 398,000, now these are expressed in degrees, and range from -3.05 to -2,80 on the longitude, and between 53.32 and 53.48 on the latitude.
[Optional exercise]
Make a map of the LSOAs that features the following characteristics:
So far we have considered many aspects of plotting a single layer of data. However, in many cases, an effective map will require more than one: for example we might want to display streets on top of the polygons of neighborhoods, and add a few points for specific locations we want to highlight. At the very heart of GIS is the possibility to combine spatial information from different sources by overlaying it on top of each other, and this is fully supported in Python.
Essentially, combining different layers on a single map boils down to adding each of them to the same axis in a sequential way, as if we were literally overlaying one on top of the previous one. For example, let us get the simplest possible plot, one with the polygons from the LSOAs and the tunnels on top of them:
f, ax = plt.subplots(1)
lsoas.plot(axes=ax)
rwy_tun.plot(axes=ax)
plt.show()
Because the default colors are not really designed to mix and match several layers, it is hard to tell them apart. However, we can use all the skills and tricks learned on styling a single layer, to make a multi-layer more sophisticated and, ultimately, useful.
f, ax = plt.subplots(1)
# Plot polygons in light grey
for poly in lsoas['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, facecolor='grey', alpha=0.25, linewidth=0.1)
# Overlay railway tunnels on top in strong green
for line in rwy_tun['geometry']:
gpd.plotting.plot_multilinestring(ax, line, color='green', linewidth=3)
plt.show()
[Optional exercise]
Create a similar map to the one above, but replace the railway tunnels by the named places points used at the beginning (and saved into namp
). Do not try to set the color to green or any other particular one, but you can play with the size of the dot.
Pro-tip: keep in mind you are plotting points no lines, so be sure to check the help of the function gpd.plotting.plot_point
.
Once we have produced a map we are content with, we might want to save it to a file so we can include it into a report, article, website, etc. Exporting maps in Python is as simple as replacing plt.show
by plt.savefig
at the end of the code block to specify where and how to save it. For example to save the previous map into a png
file in the same folder where the notebook is hosted:
f, ax = plt.subplots(1)
# Plot polygons in light grey
for poly in lsoas['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, facecolor='grey', alpha=0.25, linewidth=0.1)
# Overlay railway tunnels on top in strong green
for line in rwy_tun['geometry']:
gpd.plotting.plot_multilinestring(ax, line, color='green', linewidth=3)
plt.savefig('liverpool_railway_tunels.png')
If you now check on the folder, you'll find a png
(image) file with the map.
The command plt.savefig
contains a large number of options and additional parameters to tweak. Given the size of the figure created is not very large, we can increase this with the argument dpi
, which stands for "dots per inch" and it's a standard measure of resolution in images. For example, for a high definition (HD) quality image, we can use 1080:
[Note]: if this takes too long, try with 500 instead, which will still give you a good quality image that renders more easily.
f, ax = plt.subplots(1)
# Plot polygons in light grey
for poly in lsoas['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, facecolor='grey', alpha=0.25, linewidth=0.1)
# Overlay railway tunnels on top in strong green
for line in rwy_tun['geometry']:
gpd.plotting.plot_multilinestring(ax, line, color='green', linewidth=3)
plt.savefig('liverpool_railway_tunels.png', dpi=1080)
GeoDataFrames
)¶Once we have an understanding of how to visually display spatial information contained, let us see how it can be combined with the operations learnt in the previous session about manipulating non-spatial tabular data. Essentially, the key is to realize that a GeoDataFrame
contains most of its spatial information in a single column named geometry
, but the rest of it looks and behaves exactly like a non-spatial DataFrame
(in fact, it is). This concedes them all the flexibility and convenience that we saw in manipulating, slicing, and transforming tabular data, with the bonus that spatial data is carried away in all those steps. In addition, GeoDataFrames
also incorporate a set of explicitly spatial operations to combine and transform data. In this section, we will consider both.
Let us refresh some of the techniques we learned in the previous session about non-spatial tabular data and see how those can be combined with the mapping of their spatial counter-parts. To do this, we will revisit the population data we explored in the previous section:
import pandas as pd
# Set the path to the location of the Liverpool Census Data-Pack,
# just as you did at the beginning of the previous session
path = '../../../../data/Liverpool/'
Remember the data we want need to be extracted and renamed for the variables to have human readable names. Here we will do it all in one shot, but you can go back to the notebook of the previous session to follow the steps in more detail.
# Read original table
lsoa_orig = pd.read_csv(path+'tables/QS203EW_lsoa11.csv', index_col='GeographyCode')
# Select necessary variables
region_codes = ['QS203EW0002', 'QS203EW0032', 'QS203EW0045', \
'QS203EW0063', 'QS203EW0072']
lsoa_orig_sub = lsoa_orig.loc[:, region_codes]
# Rename variables from codes into names
variables = pd.read_csv(path+'variables_description.csv', index_col=0)
code2name = {}
lookup_table = variables.set_index('ColumnVariableCode') # Reindex to be able to query
for code in region_codes:
code2name[code] = lookup_table.loc[code, 'ColumnVariableDescription']
for code in code2name:
code2name[code] = code2name[code].replace(': Total', '')
lsoa_orig_sub = lsoa_orig_sub.rename(columns=code2name)
# Calculate total populations by area
lsoa_orig_sub['Total'] = lsoa_orig_sub.sum(axis=1)
lsoa_orig_sub.head()
Now we have both tables loaded into the session: on the one hand, the spatial data are contained in lsoas
, while all the tabular data are in lsoa_orig_sub
. To be able to work with the two together, we need to connect them. In pandas
language, this is called "join" and the key element in joins are the indices, the names assigned to each row of the tables. This is what we determine, for example, when we indicate index_col
when reading a csv
. In the case above, the index is set on GeographyCode
. In the case of the GeoDataFrame
, there is not any specific index, but a simple unamed sequence. The spatial table does have however a column called LSOA11CD
which represents the code for each polygon, and this one actually matches those in GeographyCode
in the population table.
lsoas.head()
Having the same column, albeit named differently, in both tables thus allows us to combine, "join", the two into a single one where rows are matched that we will call geo_pop
:
geo_pop = lsoas.join(lsoa_orig_sub, on='LSOA11CD')
geo_pop.head()
Let us quickly run through the logic of joins:
lsoa_orig_sub
to the spatial table lsoas
.lsoas
is a simple sequence, but the relevant codes are stored in the column LSOA11CD
.lsoa_orig_sub
is already indexed on the relevant ID codes.on
" relates to the column in the main table that is required to be matched with the index of the table being attached. In this case, the relevant ID codes are in the column LSOA11CD
, so we specify that.One final note, there appears to be a bug in the code that makes the joined table to loose the CRS. Reattaching it is straightforward:
geo_pop.crs = lsoas.crs
Once we have joined spatial and non-spatial data, we can use the techniques learned in manipulating and slicing non-spatial tables to create much richer maps. In particular, let us recall the example we worked through in the previous session in which we were selecting rows based on their population characteristics. In addition to being able to to select them, now we will also be able to visualize them in maps.
For example, let us select again the ten smallest areas of Liverpool (note how we pass a number to head
to keep that amount of rows):
smallest = geo_pop.sort('Total').head(10)
Now we can make a map of Liverpool and overlay on top of them these areas:
f, ax = plt.subplots(1, figsize=(6, 6))
# Base layer with all the areas for the background
for poly in geo_pop['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, facecolor='black', linewidth=0.025)
# Smallest areas
for poly in smallest['geometry']:
gpd.plotting.plot_multipolygon(ax, poly, alpha=1, facecolor='red', linewidth=0)
ax.set_axis_off()
f.suptitle('Areas with smallest population')
plt.axis('equal')
plt.show()
[Optional exercise. Difficulty: 4/5]
Create a map of Liverpool with the two largest areas for each of the different population subgroups in a different color each.
In addition to operations purely based on values of the table, as above, GeoDataFrame
s come built-in with a whole range of traditional GIS operations. Here we will run through a small subset of them that contains some of the most commonly used ones.
Sometimes it is useful to summarize a polygon into a single point and, for that, a good candidate is its centroid (almost like a spatial analogue of the average). The following command will return a GeoSeries
(a single column with spatial data) with the centroids of a polygon GeoDataFrame
:
cents = geo_pop.centroid
cents.head()
Note how cents
is not an entire table but a single column, or a GeoSeries
object. This means you can plot it directly, just like a table:
cents.plot()
But you don't need to call a geometry
column to inspect the spatial objects. In fact, if you do it will return an error because there is not any geometry
column, the object cents
itself is the geometry.
[Optional exercise]
Create a map with the polygons of Liverpool in the background and overlay on top of them their centroids.
Knowing whether a point is inside a polygon is conceptually a simple exercise but computationally a tricky task to perform. The simplest way to perform this operation in GeoPandas
is through the contains
method, available for each polygon object.
poly = geo_pop['geometry'][0]
pt1 = cents[0]
pt2 = cents[1]
poly.contains(pt1)
poly.contains(pt2)
Performing point-in-polygon in this way is instructive and useful for pedagogical reasons, but for cases with many points and polygons, it is not particularly efficient. In these situations, it is much more advisable to perform then as a "spatial join". If you are interested in these, see the link provided below to learn more about them.
[Optional exercise]
This one is fairly advanced, so do not dispair if you cannot solve it. Find in which polygons the named places in namp
fall into. Return, for each named place, the LSOA code in which it is located.
Buffers are one of the classical GIS operations in which an area is drawn around a particular geometry, given a specific radious. These are very useful, for instance, in combination with point-in-polygon operations to calculate accessibility, catchment areas, etc.
To create a buffer using geopandas
, simply call the buffer
method, passing in the radious. Mind that the radious needs to be specified in the same units as the CRS of the geography you are working with. For example, for the named places, we can consider their CRS:
namp.crs
These tells us it uses projection 27700 in the EPSG system. If we look it up, we will find that this corresponds with the Ordnance Survey projection, which is expressed in metres. Hence if we want, for example, a buffer of 500m. around each of these places, we can simply obtain it by:
buf = namp.buffer(500)
buf.head()
And plotting it is equally straighforward:
f, ax = plt.subplots(1)
# Plot buffer
for poly in buf:
gpd.plotting.plot_multipolygon(ax, poly, linewidth=0)
# Plot named places on top for reference
namp.plot(axes=ax)
plt.axis('equal')
plt.show()
[Optional exercise]
Generate a map of the Liverpool polygons in black and overlay on top of them yellow buffers of 250 metres around each centroid.
NOTE The folowing are extensions and as such are not required to complete this section. They are intended as additional resources to explore further possibilities that Python allows to play with representing spatial data.
[Extension I]
Creating interactive maps¶import mplleaflet
Introducing interactivity in cartography is a huge subfield that involves many considerations, most of them going well beyond the scope of this course. However, for very simple cases, it is straightforward to add geometries (points, polygons, lines) from a GeoDataFrame
to a web map based on OpenStreetMap, for example. This can be done using the additional library mplleaflet
.
As an illustration, we will display the LSOA polygons:
f, ax = plt.subplots(1)
for line in rwy_tun['geometry']:
gpd.plotting.plot_multilinestring(ax, line, color='yellow', linewidth=3)
mplleaflet.display(fig=f, crs=rwy_tun.crs)
Note that essentially, all it takes is to produce the map in the standard way and, at the end of the code block, add a line that passes the figure object (f
) and the CRS into the converter that will return the interactive map.
[Optional exercise. Difficulty: 2/5]
Create an interactive map of the smallest population areas in Liverpool
[Extension II]
Adding base layers from raster imagery¶Sometimes, in addition to vector data, we need to rely on raster sources and combine both in the same map. For all things raster, Python has the library rasterio
, which provides simplified access to raster sources and many tools for manipulation, transformation, and integration.
In this example (based on this other one by Carson Farmer), we read in open data from the Ordnance Survey for the whole UK. The data corresponds with the GB Overview Map, and is obtained through an OS Open Data license. The original source file can be downloaded from the following link:
https://www.ordnancesurvey.co.uk/opendatadownload/products.html
To import the data using rasterio
, we need to run the following code:
import rasterio
import numpy as np
# Reading in data
source = rasterio.open('figs/lab03_GBOverview.tif', 'r')
red = source.read(1)
green = source.read(2)
blue = source.read(3)
pix = np.dstack((red, green, blue))
bounds = (source.bounds.left, source.bounds.right, \
source.bounds.bottom, source.bounds.top)
Once we have the data read, plotting it follows a similar pattern as we have seen before, except now we are using the plt.imshow
command, which displays an image:
# Plotting
f = plt.figure(figsize=(6, 6))
ax = plt.imshow(pix, extent=bounds)
[Optional exercise]
Reproduce the raster map above in larger size (e.g. 14 by 14) and add the polygons of Liverpool.
[Extension III]
Advanced GIS operations¶[NOTE] If you are going to try some of these operations, make sure to have the library rtree
correctly installed.
https://github.com/geopandas/geopandas/blob/master/examples/spatial_joins.ipynb
https://github.com/geopandas/geopandas/blob/master/examples/overlays.ipynb
Geographic Data Science'15 - Lab 3 by Dani Arribas-Bel is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.