Late ballot swings in Seattle's Democratic presidential primary

A little overdue, but I finally took the time to dig into some local primary results after Washington voted on March 10th. While working on the Shaun Scott campaign I learned that Seattle election results can swing wildly after election night thanks to King County’s vote-by-mail system. Last fall, late ballots swung decisively toward the progressive candidates for city council in both the primary and general elections, and while our campaign came a few points short in D4, we closed the gap considerably after all votes were counted. In D3, Kshama Sawant famously came back from a 9-point deficit on election night to finish with a comfortable 4-point lead. Late ballot swings have also benefited the left in other states with vote by mail, as we saw in California’s 2018 Congressional elections, where several races hung in the balance for nearly two weeks before flipping to Democrats in the final count.

I wondered if the same pattern would hold in King County during Washington’s March 10th presidential primary, where the timing of early state results and candidates dropping out was bound to shake things up as well. Voting officially began on February 21st, the day before the Nevada caucuses, and didn’t finish until a week after Super Tuesday, by which time it was essentially a two-man race. I canvassed for Bernie every weekend during this period and learned first-hand how voters were grappling with the volatility of the race and what it meant for their votes. Some wanted to wait and see who would look strongest coming out of Super Tuesday; others wanted to vote as soon as possible in order to give their candidate a boost in the election night count, when the media would be paying most attention; more than a few wanted to know if they could recast their vote after their candidate dropped out early (unfortunately they could not).

I’m using two sets of data from the King County election site, the initial count on 3/10 and the final certified results from 3/20.

# Election data source: https://kingcounty.gov/depts/elections/results/2020/202003.aspx
file_initial <- "~/Downloads/20200310-election-night-precinct-results.csv"
file_final <- "~/Downloads/final-precinct-results-dem_pres_2020.csv"
file_kc_geo <- "~/Downloads/Voting_Districts_of_King_County__votdst_area/Voting_Districts_of_King_County__votdst_area.shp"

initial <- read_csv(file_initial) %>% 
  filter(Race == "President of the United States Democratic Party") %>% 
  filter(!CounterType %in% c("Registered Voters", "Times Counted", "Times Under Voted", "Times Over Voted")) %>% 
  transmute(
    precinct = str_to_upper(Precinct),
    cd = CG %>% str_replace("Congressional District ", "CD "),
    candidate = CounterType,
    initial_votes = SumOfCount
  ) 

final <- read_csv(file_final) %>% 
  filter(Race == "President of the United States Democratic Party") %>% 
  filter(!CounterType %in% c("Registered Voters", "Times Counted", "Times Under Voted", "Times Over Voted")) %>% 
  transmute(
    precinct = str_to_upper(Precinct),
    cd = CG %>% str_replace("Congressional District ", "CD "),
    candidate = CounterType,
    final_votes = SumOfCount
  ) 

kc_geo <- st_read(file_kc_geo) %>% 
  transmute(
    precinct = str_to_upper(NAME),
    geometry
  )
## Reading layer `Voting_Districts_of_King_County__votdst_area' from data source `/Users/Benjamin/Downloads/Voting_Districts_of_King_County__votdst_area/Voting_Districts_of_King_County__votdst_area.shp' using driver `ESRI Shapefile'
## Simple feature collection with 2611 features and 6 fields
## geometry type:  MULTIPOLYGON
## dimension:      XY
## bbox:           xmin: -122.5285 ymin: 47.08486 xmax: -121.0643 ymax: 47.78058
## epsg (SRID):    4326
## proj4string:    +proj=longlat +datum=WGS84 +no_defs
kc_results <-
  initial %>% 
  left_join(final, by = c("precinct", "cd", "candidate")) %>% 
  gather(key = result_set, value = votes, initial_votes, final_votes) %>% 
  mutate(result_set = if_else(result_set == "initial_votes", "Election Night", "Final Count")) %>% 
  mutate(id = 1:n())

From the topline results in King County we can see that Biden’s late bounce was just enough to put him over the top. Bernie’s vote share improved as well, but by a smaller margin. Warren’s poor performance (and fall from the 15% delegate threshold) among late voters likely reflects the fallout from a disappointing Super Tuesday, where her campaign’s promised comeback never materialized. Whatever late-ballot bounce her campaign could have hoped for from progressive voters was wiped out by a perceived lack of viability heading into the last week of voting.

final_order_kc <- 
  kc_results %>%
  filter(result_set == "Final Count") %>% 
  count(candidate, wt = votes) %>% 
  arrange(n) %>% 
  pull(candidate)

kc_results %>% 
  group_by(candidate, result_set) %>% 
  summarise(
    total_votes = sum(votes, na.rm = TRUE)
  ) %>% 
  ungroup() %>% 
  group_by(result_set) %>% 
  mutate(
    total_vote_share = total_votes / sum(total_votes)
  ) %>% 
  ungroup() %>% 
  ggplot(aes(fct_relevel(candidate, final_order_kc), total_vote_share)) +
  geom_col(fill = custom_palette[1]) + 
  geom_text(
    aes(label = scales::percent(total_vote_share)), 
    nudge_y = .04, 
    size = 3,
    color = text_color
  ) + 
  facet_wrap(. ~ result_set) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1), limits = c(0, .5)) +
  coord_flip() + 
  labs(
    title = "Topline results for King County, election night vs. final count",
    x = NULL,
    y = "King County Vote Share"
  ) + 
  custom_theme

In Seattle, Bernie managed to hold onto his initial lead, but Biden was still the biggest winner in the later ballot drops.

final_order_sea <- 
  kc_results %>%
  filter(precinct %>% str_detect("^SEA")) %>% 
  filter(result_set == "Final Count") %>% 
  count(candidate, wt = votes) %>% 
  arrange(n) %>% 
  pull(candidate)

kc_results %>% 
  filter(precinct %>% str_detect("^SEA")) %>% 
  group_by(candidate, result_set) %>% 
  summarise(
    total_votes = sum(votes, na.rm = TRUE)
  ) %>% 
  ungroup() %>% 
  group_by(result_set) %>% 
  mutate(
    total_vote_share = total_votes / sum(total_votes)
  ) %>% 
  ungroup() %>% 
  ggplot(aes(fct_relevel(candidate, final_order_sea), total_vote_share)) +
  geom_col(fill = custom_palette[1]) + 
  geom_text(
    aes(label = scales::percent(total_vote_share)), 
    nudge_y = .04, 
    size = 3,
    color = text_color
  ) + 
  facet_wrap(. ~ result_set) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1), limits = c(0, .5)) +
  coord_flip() + 
  labs(
    title = "Topline results for Seattle, election night vs. final count",
    x = NULL,
    y = "Seattle Vote Share"
  ) + 
  custom_theme

Where did Biden’s surge come from? We can look at this in a few ways. To start, let’s examine the ~10% of King County precincts that “flipped” when the final results came in, like SEA-36-2167, which went for Warren in the first batch of results but eventually went to Biden.

flipped_precincts <- 
  kc_results %>% 
  group_by(precinct, result_set) %>% 
  filter(votes == max(votes)) %>% 
  ungroup() %>% 
  select(-votes, -cd) %>% 
  spread(key = result_set, value = candidate) %>% 
  group_by(precinct) %>% 
  summarise(
    initial = max(`Election Night`, na.rm = TRUE),
    final = max(`Final Count`, na.rm = TRUE)
  ) %>% 
  filter(final != initial) 

fp_labels <- 
  flipped_precincts %>% 
  gather(key = result_set, value = candidate, -precinct) %>% 
  count(candidate, result_set) %>% 
  spread(result_set, n) %>% 
  mutate_if(is.numeric, ~ replace_na(., 0)) %>% 
  mutate(
    label = paste0(
      candidate, " (", 
      if_else(final - initial > 0, "+", ""),
      final - initial, ")")
  ) %>% 
  select(-final, -initial)

flipped_precincts %>% 
  left_join(fp_labels, by = c("initial" = "candidate")) %>% 
  left_join(fp_labels, by = c("final" = "candidate")) %>% 
  select(precinct, initial = label.x, final = label.y) %>% 
  ggplot() + 
  geom_curve(
    aes(x = "Election Night", xend = "Final Count", y = initial, yend = final),
    arrow = arrow(angle = 10, length = unit(.125, "inches")),
    curvature = .25,
    alpha = .25,
    color = custom_palette[1],
    size = .25
  ) +
  labs(
    x = NULL,
    y = NULL,
    title = "Who flipped the most precincts in the final count?"
  ) + 
  custom_theme

flipped_precincts %>% 
  count(initial, final) %>% 
  rename(`Election Night` = initial, `Final Count` = final, `Flipped Precincts` = n) %>% 
  arrange(desc(`Flipped Precincts`)) %>% 
  knitr::kable()
Election Night Final Count Flipped Precincts
Bernie Sanders Joseph R. Biden 167
Joseph R. Biden Bernie Sanders 71
Elizabeth Warren Joseph R. Biden 6
Michael Bloomberg Joseph R. Biden 4
Elizabeth Warren Bernie Sanders 2
Michael Bloomberg Bernie Sanders 1
Pete Buttigieg Joseph R. Biden 1

As expected, Biden was the big winner among the precincts that flipped. He picked up 167 precincts that Bernie won in the first count (although Bernie did manage to flip 71 Biden precincts).

biden_margins <- 
  kc_results %>% 
  group_by(precinct, result_set) %>% 
  mutate(vote_share = votes / sum(votes)) %>% 
  filter(candidate %in% c("Bernie Sanders", "Joseph R. Biden")) %>% 
  ungroup() %>% 
  select(precinct, result_set, candidate, vote_share) %>% 
  group_by(candidate, result_set) %>% 
  mutate(id = row_number()) %>% 
  spread(key = candidate, value = vote_share) %>% 
  ungroup() %>% 
  mutate(biden_margin = `Joseph R. Biden` - `Bernie Sanders`) %>% 
  select(precinct, result_set, biden_margin) %>% 
  filter(!is.nan(biden_margin))

But this set of precincts only give us a glimpse at the late-ballot swing. Let’s map the results for all of Seattle. Here are two “traditional” election maps for each set of results we’re considering.

kc_geo %>%
  filter(str_detect(precinct, "^SEA")) %>% 
  left_join(biden_margins, by = "precinct") %>% 
  left_join(flipped_precincts %>% transmute(precinct, flipped = TRUE), by = "precinct") %>% 
  mutate(flipped = replace_na(FALSE)) %>% 
  filter(!is.na(result_set)) %>% 
  ggplot(aes(fill = biden_margin)) +
  geom_sf(size = .01) +
  scale_fill_gradient2(
      low = "#1A80C4",
      high = "#CC3D41",
      labels = scales::percent,
      breaks = c(-1, -.5, 0, .5, 1),
      limits = c(-1, 1)
  ) + 
  facet_wrap(. ~ result_set) +
  coord_sf(datum = NA, xlim = c(-122.45, -122.2), ylim = c(47.5, 47.75)) +
  guides(
    fill = guide_colorbar(
      nbin = 10, 
      barheight = .25,
      barwidth = 9,
      raster = FALSE,
      ticks = FALSE,
      title.position = "top"
    )
  ) + 
  theme_void() + 
  theme(legend.position = "bottom") +
  labs(
    fill = "Biden's margin of victory"
  )

The color scale is pretty, but it isn’t suited to communicate the marginal – but, as we’ve seen, quite impactful – late-vote swing in each precinct.

kc_geo %>% 
  filter(str_detect(precinct, "^SEA")) %>% 
  mutate(center = st_centroid(geometry) %>% as.character) %>% 
  mutate(
    lat = str_extract(center, "(?<=c\\().*(?=\\,)") %>% as.double(),
    lon = str_extract(center, "(?<=\\s).*(?=\\))") %>% as.double()
  ) %>% 
  select(-center) %>% 
  left_join(biden_margins %>% spread(result_set, biden_margin) , by = "precinct") %>% 
  mutate(shift = `Final Count` - `Election Night`) %>% 
  filter(shift != 0) %>% 
  ggplot() + 
  geom_sf(size = .1, fill = NA) +
  geom_curve(
    data = . %>% filter(shift <= 0),
    aes(
      lat, 
      lon,
      color = if_else(shift > 0, "Swing for Biden", "Swing against Biden"), 
      xend = lat + .2 * shift,
      yend = lon + .025 * abs(shift)
    ), 
    arrow = arrow(length = unit(.075, "cm"), angle = 15),
    curvature = -.2,
    show.legend = TRUE,
    size = .3
  ) + 
  geom_curve(
    data = . %>% filter(shift > 0),
    aes(
      lat, 
      lon,
      color = if_else(shift > 0, "Swing for Biden", "Swing against Biden"), 
      xend = lat + .2 * shift,
      yend = lon + .025 * abs(shift)
    ), 
    arrow = arrow(length = unit(.1, "cm"), angle = 15),
    curvature = .2,
    show.legend = TRUE,
    size = .3
  ) + 
  coord_sf(datum = NA, xlim = c(-122.45, -122.2), ylim = c(47.5, 47.75)) +
  scale_color_manual(values = c("#1A80C4", "#CC3D41")) +
  theme_void() +
  labs(
    title = "Precinct-level vote swings between election night and the final count",
    color = NULL
  )

This map paints a much clearer picture. It shows that Biden managed to break open his already sizable lead in waterfront (read: wealthy) precincts, while also doing some damage in neighborhoods where Bernie did well on election night. Precincts near the University of Washington, on Capitol Hill, and in South Seattle threw late support toward Bernie, but overall Biden swung the city. In our city council race, even some of the more “moderate” parts of our district swung to the left in later counts. In this case, the effect of progressive voters casting late ballots probably mitigated Biden’s bounce, but he was still able to dominate late in the game.

I’ll close this post with a quick analysis comparing Biden’s vote share to the most recent set of Zillow’s home value index (ZHVI), which is published by neighborhood. Since King County doesn’t publish election results with respect to neighborhoods, I’ll layer the precinct data onto a neighborhood shapefile published by the city and map the intersections accordingly. Here’s a look at how this little sf shortcut turns out:

# https://github.com/jakelawlor/PNWColors/
# @Jake_Lawlor1
# devtools::install_github("jakelawlor/PNWColors") 
file_neighborhoods <- "~/Downloads/City_Clerk_Neighborhoods/City_Clerk_Neighborhoods.shp"

sea_nhoods <- 
  kc_geo %>% 
  filter(str_detect(precinct, "^SEA")) %>% 
  st_intersection(
    st_read(file_neighborhoods) %>% 
      filter(S_HOOD != "OOO", S_HOOD != "Industrial District")
  ) %>% 
  mutate(area = st_area(geometry)) %>% 
  group_by(precinct) %>% 
  filter(area == max(area)) %>% 
  ungroup() %>% 
  select(precinct, nhood = S_HOOD) %>% 
  mutate_at(vars(precinct, nhood), funs(as.character))
## Reading layer `City_Clerk_Neighborhoods' from data source `/Users/Benjamin/Downloads/City_Clerk_Neighborhoods/City_Clerk_Neighborhoods.shp' using driver `ESRI Shapefile'
## Simple feature collection with 119 features and 12 fields
## geometry type:  POLYGON
## dimension:      XY
## bbox:           xmin: -122.436 ymin: 47.49551 xmax: -122.236 ymax: 47.73416
## epsg (SRID):    4326
## proj4string:    +proj=longlat +datum=WGS84 +no_defs
pal <- PNWColors::pnw_palette("Sunset", 7, "discrete")

sea_nhoods %>% 
  ggplot() + 
  geom_sf(aes(fill = nhood), show.legend = FALSE, size = .1) + 
  scale_fill_manual(values = rep(pal, 13)) + 
  coord_sf(datum = NA) + 
  theme_void() + 
  labs(
    title = "Seattle's neighborhoods (with precincts)"
  )

Looks about right! If you’re familiar with Seattle you’ll notice there are some big chunks of land missing – those would be non-residential parts of the city with no voters and thus no election data.

Now that we have the neigborhood data, we can join the ZHVI data and do some analysis. As expected, Biden cleaned up in wealthier neighborhoods. This tracks with both common sense and my personal experience knocking doors for Shaun Scott in Windermere, where all of the houses are enormous and more than one looks like the house in Parasite, requiring a finger print scanner to get past the front gate. Bernie’s strength in lower-ZHVI neighborhoods also tracks with experience. The best day I had canvassing for his campaign was when I knocked two apartment buildings in Beacon Hill. Renters love Bernie!

# https://www.zillow.com/research/data/
# Zillow Home Value Index
zillow <- read_csv("~/Downloads/Neighborhood_Zhvi_AllHomes.csv") %>% 
  filter(State == "WA", City == "Seattle") %>% 
  select(id = RegionID, nhood = RegionName, zhvi = `2020-03-31`)

sea_nhoods %>% 
  st_set_geometry(value = NULL) %>% 
  left_join(biden_margins, by = "precinct") %>% 
  left_join(zillow, by = "nhood") %>% 
  filter(result_set == "Final Count") %>% 
  ggplot(aes(biden_margin, zhvi)) + 
  geom_hline(yintercept = median(zillow$zhvi), color = text_color, lty = 2) + 
  geom_vline(xintercept = 0, color = text_color, lty = 2) + 
  geom_point(color = custom_palette[1], alpha = .75) + 
  geom_smooth(method = "lm", color = custom_palette[2], alpha = .15) + 
  geom_text(
    data = tibble(zhvi = median(zillow$zhvi) + 30000, biden_margin = .9, label = "Median ZHVI"),
    aes(label = label),
    color = text_color,
    size = 3
  ) + 
  scale_y_log10(labels = scales::dollar) + 
  scale_x_continuous(labels = scales::percent) + 
  labs(
    title = "Zillow Home Value Index vs. Biden's primary performance in Seattle",
    y = "ZHVI",
    x = "Biden's margin of victory",
    subtitle = "Each point represents a precinct",
    caption = "https://www.zillow.com/research/data/"
  ) + 
  custom_theme

Lastly, here’s a full list of neighborhoods – if you’re local, maybe you can spot yours. Thanks for reading!

sea_nhoods %>% 
  st_set_geometry(value = NULL) %>% 
  left_join(biden_margins, by = "precinct") %>% 
  left_join(zillow, by = "nhood") %>% 
  filter(result_set == "Final Count") %>% 
  ggplot(aes(reorder(nhood, biden_margin), biden_margin)) + 
  geom_hline(yintercept = 0, lty = 2, color = "red") + 
  geom_boxplot(aes(fill = zhvi), alpha = .75, color = text_color, size = .25) +
  coord_flip() +
  scale_y_continuous(labels = scales::percent) + 
  scale_fill_gradient(
    breaks = c(500000, 1600000), 
    labels = c("$", "$$$"),
    low = custom_palette[5],
    high = custom_palette[1]
  ) + 
  guides(fill = guide_colorbar(ticks = FALSE)) +
  labs(
    x = "Neighborhood",
    y = "Biden's margin of victory",
    title = "Biden's performance in Seattle by neighborhood",
    fill = "ZHVI",
    caption = "https://www.zillow.com/research/data/"
  ) + 
  custom_theme

Related