Advanced Example: Optimizing Using Geographic Quantities

In the previous section we saw how to use the SingleMetricOptimizer to optimize for plans that contain the minimum number of cut edges. However, sometimes we may wish to include other geographic quantities in our optimization equation. Obtaining and working with this sort of data can be a bit more involved, so here we will show how to use the requests package to download data directly from the Census API and how to use that data for optimization.

Getting the Data

First, we will need to install the requests package:

pip install requests

Then we can import the package in the head of our jupyter notebook and download the data:

import os
import pandas as pd
import requests

shape_url = "https://www2.census.gov/geo/tiger/TIGER2020PL/LAYER/BG/2020/tl_2020_01_bg20.zip"

shape_response = requests.get(shape_url)
shape_response.raise_for_status()

with open("tl_2020_01_bg20.zip", "wb") as f:
    f.write(shape_response.content)

pop_url = "https://api.census.gov/data/2020/dec/pl"
pop_params = {
    "get": "group(P1)",
    "for": "block group:*",
    "in": "state:01 county:*",
 }


# Note that you will need to get your own Census API key and set it as an environment variable
# or replace the "REPLACE_WITH_YOUR_KEY" string with your key.
census_api_key = os.environ.get("CENSUS_API_KEY", "REPLACE_WITH_YOUR_KEY")

pop_params["key"] = census_api_key

pop_response = requests.get(
    pop_url,
    params=pop_params,
    timeout=30,
)
pop_response.raise_for_status()

pop_df = pd.DataFrame(pop_response.json()[1:], columns=pop_response.json()[0])

pop_df.to_pickle("01_bg_P1_2020.pkl")

You can request a key from the Census API key signup page. Store it in the CENSUS_API_KEY environment variable rather than putting it directly in your notebook. We have saved the downloaded data for later so that it is easier to use in the next section.

Preparing the Data

We are now ready to work with the optimizer. First, we will need to import the necessary packages:

from gerrychain import Graph, GeographicPartition, MarkovChain
from gerrychain.updaters import Tally
from gerrychain.metrics.compactness import polsby_popper
from gerrychain.proposals import recom, build_recom_proposal_fn
from gerrychain.tree import bipartition_tree
from gerrychain.accept import always_accept
from gerrychain.optimization import SingleMetricOptimizer
from functools import partial
import numpy as np
import pandas as pd
import geopandas as gpd
import random

rng = random.Random(2024)

Now we need to import the data and check the Coordinate Reference System (CRS) for reasons that will be explained momentarily:

gdf = gpd.read_file("tl_2020_01_bg20.zip")
gdf.crs
<Geographic 2D CRS: EPSG:4269>
Name: NAD83
Axis Info [ellipsoidal]:
- Lat[north]: Geodetic latitude (degree)
- Lon[east]: Geodetic longitude (degree)
Area of Use:
- name: North America - onshore and offshore: Canada - Alberta; British Columbia; Manitoba; New Brunswick; Newfoundland and Labrador; Northwest Territories; Nova Scotia; Nunavut; Ontario; Prince Edward Island; Quebec; Saskatchewan; Yukon. Puerto Rico. United States (USA) - Alabama; Alaska; Arizona; Arkansas; California; Colorado; Connecticut; Delaware; Florida; Georgia; Hawaii; Idaho; Illinois; Indiana; Iowa; Kansas; Kentucky; Louisiana; Maine; Maryland; Massachusetts; Michigan; Minnesota; Mississippi; Missouri; Montana; Nebraska; Nevada; New Hampshire; New Jersey; New Mexico; New York; North Carolina; North Dakota; Ohio; Oklahoma; Oregon; Pennsylvania; Rhode Island; South Carolina; South Dakota; Tennessee; Texas; Utah; Vermont; Virginia; Washington; West Virginia; Wisconsin; Wyoming. US Virgin Islands. British Virgin Islands.
- bounds: (167.65, 14.92, -40.73, 86.45)
Datum: North American Datum 1983
- Ellipsoid: GRS 1980
- Prime Meridian: Greenwich

In this example, we will be interested in optimizing the average Polsby-Popper score for the example data, but before we can do that, we need to make sure that the CRS for our data is appropriate for the measurements we wish to take. For this example, this means that we would like avoid coordinate systems like the geographic coordinate system (lat, long) which measures distances in degrees in favor of something like a Mercator or transverse Mercator projection which measures distances in meters and is subject to less distortion.

We can see that the Census uses the CRS EPSG:4269 which is a geographic coordinate system and not useful for computing things like the Polsby-Popper score, so we need to transform the data mildly before we can use it. In general, to find a good choice of CRS, it is best to consult the official EPSG website epsg.io. In this case, we are working with Alabama, and we know that the Albers Equal Area Conic projection with epsg code 5070 is a good choice. So we will modify our geodataframe to use this CRS and then make a graph from it:

gdf.to_crs(epsg=5070, inplace=True)
graph = Graph.from_geodataframe(gdf)
# And we should check to make sure that the CRS is set properly
graph.data.crs
<Projected CRS: EPSG:5070>
Name: NAD83 / Conus Albers
Axis Info [cartesian]:
- X[east]: Easting (metre)
- Y[north]: Northing (metre)
Area of Use:
- name: United States (USA) - CONUS onshore - Alabama; Arizona; Arkansas; California; Colorado; Connecticut; Delaware; Florida; Georgia; Idaho; Illinois; Indiana; Iowa; Kansas; Kentucky; Louisiana; Maine; Maryland; Massachusetts; Michigan; Minnesota; Mississippi; Missouri; Montana; Nebraska; Nevada; New Hampshire; New Jersey; New Mexico; New York; North Carolina; North Dakota; Ohio; Oklahoma; Oregon; Pennsylvania; Rhode Island; South Carolina; South Dakota; Tennessee; Texas; Utah; Vermont; Virginia; Washington; West Virginia; Wisconsin; Wyoming.
- bounds: (-124.79, 24.41, -66.91, 49.38)
Coordinate Operation:
- name: Conus Albers
- method: Albers Equal Area
Datum: North American Datum 1983
- Ellipsoid: GRS 1980
- Prime Meridian: Greenwich

Since we used a shapefile that was directly from the US Census, we will need to add in the population data to the graph. Here we have the 2020 P1 table from the US Census with the column “P1_001N” corresponding to the total population of each geograpic unit.

population_data = pd.read_pickle("01_bg_P1_2020.pkl")
population_columns = ["GEO_ID", "NAME", "P1_001N"]
population_data[population_columns].head()
                      GEO_ID                                                     NAME P1_001N
0  1500000US010610503006  Block Group 6, Census Tract 503, Geneva County, Alabama     639
1  1500000US010610504003  Block Group 3, Census Tract 504, Geneva County, Alabama     950
2  1500000US010610505001  Block Group 1, Census Tract 505, Geneva County, Alabama    1158
3  1500000US010610505004  Block Group 4, Census Tract 505, Geneva County, Alabama    1022
4  1500000US010610506003  Block Group 3, Census Tract 506, Geneva County, Alabama    1386

We now need to merge the population data with the graph data. Unfortunately, the Census data for the shapefile and for the P1 table do not have consistent formatting for the geoids, so we need to fix that.

# This grabs the UID part of the GEO_ID column and puts it into a new column called GEOID20
population_data["GEOID20"] = population_data["GEO_ID"].apply(lambda x: x.split("US")[1])

full_data = pd.merge(population_data, graph.data, on='GEOID20')

# Check to make sure the number of rows in the full data is the same as the number of rows
# in the graph.data dataframe. This is a sanity check to make sure we didn't lose any data.
assert full_data.shape[0] == graph.data.shape[0]

full_data.set_index("GEOID20", inplace=True)
full_columns = ["GEO_ID", "P1_001N", "STATEFP20", "COUNTYFP20", "ALAND20"]
full_data[full_columns].head()

And now we can add the population data to the graph:

for node_id in graph.node_indices:
    node_data = graph.node_data(node_id)
    geo_id = node_data["GEOID20"]
    # Note that the pops are np.int64 types, so we convert them to ints here
    node_data["TOTPOP"] = int(full_data.at[geo_id, "P1_001N"])

graph.node_data(0)
{'boundary_node': False,
'area': 1100226.0333901227,
'STATEFP20': '01',
'COUNTYFP20': '033',
'TRACTCE20': '020200',
'BLKGRPCE20': '1',
'GEOID20': '010330202001',
'NAMELSAD20': 'Block Group 1',
'MTFCC20': 'G5030',
'FUNCSTAT20': 'S',
'ALAND20': 994584,
'AWATER20': 105643,
'INTPTLAT20': '+34.7664259',
'INTPTLON20': '-087.6960323',
'geometry': <POLYGON ((752587.971 1333045.744, 752581.759 1333117.737, 752581.362 133313...>,
'TOTPOP': 1171}

Using SingleMetricOptimizer

There is a polsby_popper updater in GerryChain, but you need to make sure that the CRS for your data is correct before you use it. If it is not, then you can run into some issues with distortion of the geometry. However, we already checked that our CRS is appropriate in this tutorial, so we don’t need to worry. We can now follow the usual workflow laid out in the optimization tutorial with the only tweak being that we need to use a GeographicPartition object instead of a regular Partition object for the SingleMetricOptimizer since we want to use the polsby_popper updater which relies on the area and perimeter updaters.

updaters = {
    "population": Tally("TOTPOP", alias="population"),
    "polsby-popper": polsby_popper,
}

# We need to use the GeographicPartition class to make sure that area and perimeter
# updaters are also calculated. These are used in the calculation of the polsby-popper
# score.
initial_partition = GeographicPartition.from_random_assignment(
    graph=graph,
    n_parts=4,
    epsilon=0.01,
    pop_col="TOTPOP",
    updaters=updaters,
    rng=rng,
)

ideal_population = sum(initial_partition["population"].values())/len(initial_partition)

opt_metric = lambda x: sum(x["polsby-popper"].values())/len(x)

POPCOL = "TOTPOP"
EPS = 0.02
TOTPOP = sum(graph.node_data(node_id)[POPCOL] for node_id in graph.node_indices)

proposal_fn = build_recom_proposal_fn(
    pop_col=POPCOL,
    pop_target=TOTPOP/4,
    epsilon=EPS,
)

optimizer = SingleMetricOptimizer(
    initial_state=initial_partition,
    proposal_fn=proposal_fn,
    constraints=[],
    optimization_metric_fn=opt_metric,
    maximize=True,
    rng=rng,
)


tilt_best = -1
for i, part in enumerate(optimizer.tilted_run(1000, 0.1, with_progress_bar=False)):
    tilt_best = max(tilt_best, opt_metric(part))


print(tilt_best)