How many people will vote in the Primary?

One question I get a lot is what we should expect for turnout in this primary. We have a lot of mixed signals, and it can be hard to intuit what they all mean together.

The signals include:

  • This is a Primary with an incumbent mayor, which typically sees a low 140,000 voters.
  • But turnout has soared after 2016. It was was 66% higher for the 2017 primary than the prior three DA primaries, and 35% higher for the 2018 general than the prior three Gubernatorial generals.
  • The turnout surge was especially large in neighborhoods that voted for Krasner.
  • The Democratic Primary is currently at 29 Council At Large and 13 Commissioner candidates, versus 16 and 6 in 2015.
  • We have contested primaries in 5 of 10 council districts: 1, 2, 3, 4, and 7. In 2015, the only contested districts were Kanyatta Johnson’s 2nd and Maria Quiñones-Sánchez’s 7th.

What does it all mean? In this post, I’ll sort through the recent trends, and make a prediction (or really, two) for what turnout will look like.

View code
library(ggplot2)
library(dplyr)
library(ggthemes)
library(scales)
library(colorspace)
library(tidyr)
library(sf)


setwd("C:/Users/Jonathan Tannen/Dropbox/sixty_six/posts/turnout_2019_primary/")

load("../../data/processed_data/df_major_2017_12_01.Rda")

source("../../admin_scripts/util.R")

turnout <- df_major %>%
  filter(OFFICE %in% c(
    "PRESIDENT OF THE UNITED STATES",
    "GOVERNOR",
    "MAYOR",
    "DISTRICT ATTORNEY"
  )) %>%
  group_by(WARD16, DIV16, year, election) %>%
  summarise(VOTES = sum(VOTES))


df_2018 <- read.csv("../../data/raw_election_data/2018_general.csv")
names(df_2018)<- c(
  "WARD16", "DIV16", "TYPE", "OFFICE", 
  "CANDIDATE", "PARTY", "VOTES"
)
df_2018$WARD16 <- sprintf("%02d", df_2018$WARD16)
df_2018$DIV16 <- sprintf("%02d", df_2018$DIV16)

df_2018 <- df_2018 %>% 
  filter(OFFICE == "GOVERNOR AND LIEUTENANT GOVERNOR") %>%
  group_by(WARD16, DIV16) %>%
  summarise(VOTES = sum(VOTES)) 

df_2018$election <- "general"
df_2018$year <- "2018"

df_2018_primary <- read.csv("../../data/raw_election_data/2018_primary.csv")
# head(df_2018_primary)
names(df_2018_primary)<- c(
  "WARD16", "DIV16", "TYPE", "OFFICE", 
  "CANDIDATE", "PARTY", "VOTES"
)
df_2018_primary$WARD16 <- sprintf("%02d", df_2018_primary$WARD16)
df_2018_primary$DIV16 <- sprintf("%02d", df_2018_primary$DIV16)
df_2018_primary <- df_2018_primary %>% 
  filter(PARTY == "DEMOCRATIC") %>%
  mutate(OFFICE = gsub("(.*)-DEM", "\\1", OFFICE)) df_2018_primary <- df_2018_primary %>% filter(OFFICE == "GOVERNOR") %>% group_by(WARD16, DIV16) %>% summarise(VOTES = sum(VOTES)) df_2018_primary$election <- "primary" df_2018_primary$year <- "2018" turnout <- bind_rows(turnout, df_2018) turnout <- bind_rows(turnout, df_2018_primary) turnout_wide <- turnout %>% unite(key, election, year) %>% spread(key = key, value = VOTES) cycles <- data.frame( year = 2002:2021, cycle = rep(c("Governor","Mayor","President","District Attorney"), 5), senate = rep(c(FALSE, FALSE, TRUE, FALSE, TRUE, FALSE), 4)[1:20] ) turnout_total <- turnout %>% group_by(year, election) %>% summarise(VOTES = sum(VOTES)) turnout_total <- turnout_total %>% left_join( cycles %>% mutate(year = as.character(year)), by = "year" ) 

The Typical Turnout in an Incumbent Mayoral Primary

First, let’s consider the boring historical, pre-2016 baseline. Mayoral primaries have the second highest turnout in the city, second only to Presidential ones, but much lower turnout when there’s an incumbent mayor.

View code
annotation_df <- list(
  primary = tribble(
    ~year, ~VOTES, ~cycle, ~hjust,
    13, 75e3, "District Attorney", 0,
    14, 375e3, "President", 1,
    14, 180e3, "Governor", 0,
    14, 270e3, "Mayor", 0.5
  ),
  general = tribble(
    ~year, ~VOTES, ~cycle, ~hjust,
    13.5, 120e3, "District Attorney", 0,
    12, 675e3, "President", 0,
    14.2, 400e3, "Governor", 0,
    14.3, 248e3, "Mayor", 0
  )
)


senate_label_pos <- list(
  primary = tribble(
    ~year, ~VOTES, ~senate, ~label,
    2.3, 25e3, TRUE, "Senate",
    2.3, 50e3, FALSE, "Non-Senate"
  ),
  general = tribble(
    ~year, ~VOTES, ~senate, ~label,
    2.3, 37.5e3, TRUE, "Senate",
    2.3, 75e3, FALSE, "Non-Senate"
  )
)


turnout_plot <- function(use_election){
  ggplot(
    turnout_total %>% filter(election == use_election), 
    aes(
      x = year, 
      y = VOTES,
      color = cycle,
      group = interaction(cycle, election)
    )
  ) + 
    geom_point(size = 3, aes(shape = senate)) +
    geom_line() +
    expand_limits(y = 0) +
    scale_y_continuous("Votes Cast", labels = comma) +
    theme_sixtysix() +
    theme(axis.title.x = element_blank()) +
    geom_point(
      data = senate_label_pos[[use_election]],
      x = 2,
      aes(shape = senate),
      color = "grey20",
      group = NA,
      size = 3
    )+
    geom_text(
      data = senate_label_pos[[use_election]],
      aes(label=label),
      color = "grey20",
      group = NA,
      size = 4,
      hjust = 0
    )+
    geom_text(
      data = annotation_df[[use_election]],
      aes(label=cycle, hjust=hjust, color=cycle),
      group = NA,
      size = 4,
      fontface="bold"
    )+
    scale_shape_discrete(guide = FALSE)+
    scale_color_discrete(guide = FALSE)+
    ggtitle(sprintf(
      "Turnout in Philadelphia %s",
      ifelse(use_election == "general", "Generals", "Democratic Primaries")
    ))
}

In the 2011 primary, with Nutter running for reelection, 166,000 Philadelphians cast a vote. In 2003, a year in which Street ran unopposed in the primary but was divisive enough to draw a strong challenge in the general, 113,000 voted in the primary.

View code
turnout_plot("primary")

plot of chunk primary_turnout

We might start with a baseline guess of the average: 140,000 votes. We might, that is, if we hadn’t seen the last two years.

The post-2016 surge

Turnout since 2016 has fundamentally changed from the years before. In the plot above, notice that 165,000 Philadelphians voted in the 2017 District Attorney primary, 2.6 times the turnout of four years before (and 1.7 times the average turnout of the prior three DA primaries). Then the 2018 general turnout was astromical, approaching Presidential election numbers. The 554,000 votes cast was 36% higher than the 409,000 average of the four prior Gubernatorial generals.

View code
turnout_plot("general")

plot of chunk general_turnout

That turnout surge wasn’t uniform, but disproportionately occured in the gentrifying ring around Center City: University City, South Philly, and the River Wards (which I’ll call Krasner’s Base, as we’ll see later). Turnout was 3x the typical turnout for a DA election in those wards in 2017, and 2x the typical Gubernatorial turnout in 2018.

View code
da_results <- df_major %>%
  filter(
    election == "primary" & PARTY == "DEMOCRATIC" & OFFICE == "DISTRICT ATTORNEY"
  ) %>%
  group_by(year, WARD16, DIV16, CANDIDATE) %>%
  summarise(VOTES = sum(VOTES)) %>%
  group_by(year, WARD16, DIV16) %>%
  mutate(
    total_votes = sum(VOTES),
    pct_vote = VOTES / total_votes
  )

da_results$candidate_name <- format_name(da_results$CANDIDATE)

turnout_wide <- turnout_wide %>%
  mutate(
    typical_turnout_da = (primary_2013 + primary_2009 + primary_2005)/3,
    typical_turnout_governor = (general_2014 + general_2010 + general_2006 + general_2002)/4
  )

krasner_results <- da_results %>% 
  filter(candidate_name == "Lawrence S Krasner") %>%
  left_join(turnout_wide)
View code
library(sf)

divs <- st_read("../../data/gis/2016/2016_Ward_Divisions.shp", quiet = TRUE)
divs <- divs %>% st_transform(2272)
wards <- st_read("../../data/gis/2016/2016_Wards.shp",  quiet = TRUE)
wards <- wards %>% st_transform(2272)

divs$area <- as.numeric(st_area(divs$geometry)) / (5280^2)
wards$area <- wards$AREA_SFT / (5280^2)

divs <- st_simplify(divs, 500)
divs <- divs %>% mutate(
  WARD16 = sprintf("%02d", WARD),
  DIV16 = sprintf("%02d", DIVSN)
) %>% select(-WARD, -DIVSN)
wards$WARD16 = sprintf("%02d", asnum(wards$WARD))
View code
krasner_results_wards <- krasner_results %>%
  group_by(WARD16) %>%
  summarise(
    turnout_2017 = sum(primary_2017),
    typical_turnout_da = sum(typical_turnout_da),
    pct_vote = weighted.mean(pct_vote, w=total_votes)
  )

turnout_wide_wards <- turnout_wide %>%
  group_by(WARD16) %>%
  summarise_at(
    .funs = funs(sum(., na.rm = TRUE)), 
    vars(
      starts_with("primary_"), 
      starts_with("general_"), 
      starts_with("typical_turnout_")
    )
  )

krasner_turnout_per_mile <- ggplot(
  # divs %>%
  #   left_join(turnout_wide)
  wards %>% 
    left_join(turnout_wide_wards)
) +
  geom_sf(
    aes(
      fill = pmin(primary_2017 / area, 10e3)
    ), 
    color = NA
  ) +
  scale_fill_viridis_c(
    "Votes per mile",
    labels = scales::comma
  ) +
  theme_map_sixtysix() +
  ggtitle("Votes per mile in the 2017 primary")

krasner_turnout_change <- ggplot(
  # divs %>%
  #   left_join(turnout_wide)
  wards %>%
    left_join(turnout_wide_wards)
) +
  geom_sf(
    aes(fill = pmin(primary_2017 / typical_turnout_da, 4)), 
    color = NA
  ) +
  expand_limits(fill = c(1,3)) +
  scale_fill_viridis_c(
    "Turnout in 2017/\n Typical DA Turnout",
    # breaks = 0:5,
    labels = function(x) paste0(x, "x")
  ) +
  theme_map_sixtysix() +
  ggtitle("Surged nearly 3x in Krasner's base")

gridExtra::grid.arrange(
  krasner_turnout_per_mile,
  krasner_turnout_change, 
  nrow=1  
)

plot of chunk krasner_turnout_maps

View code
votes_per_mile_2018 <- ggplot(
  # divs %>%
    # left_join(turnout_wide)
    wards %>% left_join(turnout_wide_wards)
) +
  geom_sf(
    aes(
      fill = pmin(general_2018 / area, 20e3)
    ), 
    color = NA
  ) +
  scale_fill_viridis_c(
    "Votes per mile",
    labels = scales::comma
  ) +
  theme_map_sixtysix() +
  ggtitle("Votes per mile in the 2018 general")

turnout_change_2018 <- ggplot(
  # divs %>%
  #   left_join(turnout_wide)
  wards %>%
    left_join(turnout_wide_wards)
) +
  geom_sf(
    aes(fill = pmin(general_2018 / typical_turnout_governor, 4)), 
    color = NA
  ) +
  expand_limits(fill = c(1, 3)) +
  scale_fill_viridis_c(
    "Turnout in 2018/\n Typical Governor Turnout",
    labels = function(x) paste0(x, "x")
  ) +
  theme_map_sixtysix() +
  ggtitle(
    'Surged "only" 2x in Krasner\'s base'
  )

gridExtra::grid.arrange(
  votes_per_mile_2018,
  turnout_change_2018, 
  nrow=1  
)

plot of chunk turnout_maps_2018

Why do I label those wards as Krasner’s base? Because they’re exactly where Krasner did strongest, winning over 60% of the votes (in a multi-candidate race!). Here’s the map of the District Attorney’s votes (mapped by Division).

View code
krasner_pct_map <- ggplot(
  divs %>% left_join(krasner_results)
  # wards %>% 
  #   left_join(krasner_results_wards)
) +
  geom_sf(aes(fill = 100 * pct_vote), color = NA) +
  scale_fill_viridis_c("Percent\n of vote") +
  theme_map_sixtysix() +
  ggtitle("Percent of vote for Krasner")

print(krasner_pct_map)

plot of chunk krasner_results

The story is clear: there is a specific population that used to never vote that’s been activated by 2016. These are the predominantly young, predominantly White residents of rapidly gentrifying wards. The votes in those neighborhoods are converging to the high-turnout behaviors regularly seen in core Center City and the Northwest.

Plotting the increase in turnout in 2017 versus Krasner’s percent of the vote shows that divisions everywhere voted at least 1.5x as much as the prior three DA races, but over twice as much where Krasner won more than 50% of the vote.

View code
ggplot(
  krasner_results,
  aes(
    y = primary_2017 / typical_turnout_da,
    x = 100 *pct_vote
  )
) +
  geom_point(
    aes(
      size = typical_turnout_da
    ),
    alpha = 0.3,
    color = strong_green,
    pch = 16
  ) +
  geom_smooth(
    aes(weight = typical_turnout_da), 
    color = "grey10"
  )+
  scale_size_area("Division's average votes\nin 2005, 2009, 2013") +
  scale_y_continuous(
    labels = function(x) return(paste0(x, "x")),
    limits = c(0, 8),
    breaks = seq(0, 10, 2)
  ) +
  geom_hline(yintercept = 1, linetype="dashed") +
  annotate(
    "text", 
    x=55, y=0.95,
    label="2017 turnout = typical turnout",
    hjust=0, vjust=1
  ) +
  labs(
    title = "Krasner's popularity also drove turnout",
    subtitle = "Democratic Primary turnout was 1.5x in low-support divisions, but 3x in high",
    y = "Votes in 2017 / Average votes in 2005, 2009, 2013",
    x = "Percent of vote for Krasner"
  )+
  theme_sixtysix()

plot of chunk krasner_scatter

District Council Races

Finally, what do the competitive Council races imply?

I don’t know what to do with the increase in candidates for At Large races, which clearly represents something but is such an outlier that there’s no responsible way to use it. But the increase in competitive district races will have a clear impact on the election, which we can measure.

View code
load_council_races <- function(year){
  df_year <- read.csv(
    paste0("../../data/raw_election_data/",year,"_primary.csv")
  )

  df_year <- df_year %>%
    mutate(
      WARD16 = sprintf("%02d", asnum(WARD)),
      DIV16 = sprintf("%02d", asnum(DIVISION))
    ) %>%
    group_by(WARD16, DIV16, OFFICE, CANDIDATE, PARTY) %>%
    summarise(VOTES = sum(VOTES)) %>%
    group_by()


  district_regex <- "DISTRICT COUNCIL(-|\\s)([0-9]+)[A-Z]+ DIST(RICT)?-D(EM)?"
  council_districts <- df_year %>%
    filter(grepl(district_regex, OFFICE)) %>%
    mutate(
      district = asnum(gsub(district_regex, "\\2", OFFICE))
    ) %>%
    mutate(
      candidate_name = format_name(CANDIDATE),
      last_name = get_last_name(candidate_name),
      year = year
    )
  return(council_districts)
}

council_2015 <- load_council_races(2015)
council_2011 <- load_council_races(2011)
council_2007 <- load_council_races(2007)
council_2003 <- load_council_races(2003)

council_df <- bind_rows(
  council_2015, council_2011, council_2007, council_2003
)

council_totals <- council_df %>%
  group_by(year, candidate_name, district) %>%
  summarise(votes = sum(VOTES)) %>%
  arrange(year, district, desc(votes))

council_races <- council_totals %>%
  group_by(year, district) %>%
  summarise(
    winner = candidate_name[which.max(votes)],
    pct_winner = max(votes) / sum(votes),
    is_competitive = pct_winner < 0.9
  ) %>%
  group_by()

council_turnout <- turnout %>% 
  rename(total_votes = VOTES) %>%
  group_by() %>%
  filter(
    election == "primary" &
      year %in% seq(2003, 2015, 4)
  ) %>%
  mutate(year = asnum(year)) %>%
  left_join(
    council_df %>%
      select(WARD16, DIV16, district) %>%
      unique()
  ) %>%
  left_join(council_races)

## FYI: 2015 did not have a Dem Primary in the 10th

council_turnout$is_competitive <- with(
  council_turnout,
  replace(is_competitive, is.na(is_competitive), FALSE)
)

council_turnout <- council_turnout %>%
  mutate(WARD_DIVSN = paste0(WARD16, DIV16))

council_turnout <- council_turnout %>%
  mutate(competitive_mayor = year %in% c(2015, 2007))


fit_competitive <- lm(
  log(total_votes + 1) ~ 
    as.character(year) +
    WARD_DIVSN +
    is_competitive * competitive_mayor,
  data = council_turnout
) 

coef_council_is_competitiveTRUE <- coef(fit_competitive)['is_competitiveTRUE']

coef_council_mayor_is_competitive_interaction <- coef(fit_competitive)['is_competitiveTRUE:competitive_mayorTRUE']

# ncoef <- length(fit_competitive$coefficients)
# fit_competitive$coefficients %>% tail(4)
# vcov <-vcov(fit_competitive)
# vcov[(nrow(vcov)-2):nrow(vcov), (nrow(vcov)-2):nrow(vcov)] %>% diag %>% sqrt

In an incumbent Mayoral election, the competitive Council districts have turnout 15.3% higher than the non-competitive districts (this estimate includes year and division fixed effects, to control for divisions’ individual turnouts and overall annual swings). Competitive districts only have 3.8% higher turnout when the Mayor’s seat is open, since everyone votes anyway.

Tying it all together

What does this all mean? I’ll make two projections: Low, the pre-2016 typical turnout; and High, assuming the post-2018 surge continues.

Low:

  • Each division’s average turnout for incumbent Mayoral Elections (2003 and 2011)
  • with the 1, 2, 3, 4, and 7th Council Districts contested
  • using typical pre-2016 turnout.

High:

  • Each division’s turnout for only 2011 (since 2003 was distinctly low)
  • with the 1, 2, 3, 4, and 7th Council Districts contested
  • using each division’s proportional surge in 2018.

I’ll only use the 2018 proportional surge (and not the higher 2017 primary surge) because a mayoral race has baseline turnout more similar to a gubernatorial general than a DA primary, so the DA’s race just had much more room for turnout to grow.

For the high-turnout projection I only use 2011 as the baseline, because 2003 had particularly low turnout even for an uncontested primary. This was probably due to relative discontent with incumbent Street, the same sentiment that led to Republican Katz’s strong (but still not close) performance in the general.

View code
projected_turnout <- turnout_wide %>%
  left_join(
    council_df %>%
      filter(year == 2015) %>%
      select(WARD16, DIV16, district) %>%
      unique()
  ) 

projected_turnout <- projected_turnout %>%
  mutate(
    is_contested_2019 = district %in% c(1,2,3,4,7)
  ) %>%
  left_join(
    council_races %>%
      select(year, district, is_competitive) %>%
      filter(year %in% c(2011, 2003)) %>%
      mutate(year = paste0("was_contested_", year)) %>%
      spread(year, is_competitive, fill=FALSE)
  ) 

projected_turnout <- projected_turnout %>%
  mutate(
    baseline_turnout_avg = 0.5 * (
      primary_2003 / ifelse(
        was_contested_2003, exp(coef_council_is_competitiveTRUE), 1 
      ) + 
        primary_2011 / ifelse(
          was_contested_2011, exp(coef_council_is_competitiveTRUE), 1
        )
    ),
    baseline_turnout_2011 =
      primary_2011 / ifelse(
        was_contested_2011, exp(coef_council_is_competitiveTRUE), 1
      )
  )

projected_turnout <- projected_turnout %>%
  mutate(
    competitive_scaling = ifelse(
      is_contested_2019, exp(coef_council_is_competitiveTRUE), 1
    )
  )

projected_turnout <- projected_turnout %>%
  left_join(
    turnout_wide %>% 
      select(
        WARD16, DIV16, primary_2017, 
        general_2018, typical_turnout_da, typical_turnout_governor
      ) %>%
      mutate(
        scale_2017 = primary_2017 / typical_turnout_da,
        scale_2018 = general_2018 / typical_turnout_governor
      )
  ) %>%
  mutate(
    high_projection = baseline_turnout_2011 *
      competitive_scaling *
      # (scale_2018 + scale_2017)/2
      scale_2018,
    low_projection = baseline_turnout_avg *
      competitive_scaling
  )

baseline_turnout <- sum(
  projected_turnout$low_projection,
  na.rm = TRUE
)
surged_turnout <- sum(
  projected_turnout$high_projection, 
  na.rm = TRUE
)

turnout_2019 <- tribble(
  ~year, ~election, ~cycle, ~senate,
  '2019', "primary", "Mayor", FALSE
) %>% full_join(
  data.frame(
    year = '2019',
    sim = c("Low", "High"),
    VOTES = c(baseline_turnout, surged_turnout)
  )
)

turnout_plot("primary") +
  geom_point(
    data=turnout_2019,
    size=3
  ) +
  geom_segment(
    data=turnout_2019,
    aes(
      xend=year,
      yend=VOTES,
      color=cycle
    ),
    x=14,
    y=turnout_total %>% 
      filter(year == 2015 & election == "primary") %>% 
      with(VOTES),
    linetype="dashed"
  ) +
  geom_text(
    data=turnout_2019,
    aes(label = sim),
    vjust = -1
  ) +
  labs(subtitle = "Projections under pre- and post-2016 assumptions.")

plot of chunk projections,

Under typical, pre-2016 assumptions, we would expect 140,000 votes. A surge proportional to the 2018 general would lead to 218,000 votes. Both of these are lower than 2015’s 247,000, because it’s just so hard to match the energy of a competitive Mayoral race, even post-2016. The surging divisions of Krasner’s Base will probably come out strong, but their turnout numbers aren’t strong enough to overcome the presumably typical turnout in the rest of the city.

If you pinned me down, my guess is turnout will be somewhere closer to the High projection. I doubt we’ll reachieve the surge of the 2018 general because that was fueled by huge national attention, and the first post-Trump national race. But there’s certainly something different from 2011, just given the number of candidates alone.

You can download the division-level projections from github. (NOTE: the division-level projections are super noisy, and a few divisions have missing values because of boundaries that don’t line up with any available boundary file (in the 5th Ward). The ward-level sums should be largely right, but read the individual divisions with some caution.)

View code
write.csv(
  projected_turnout %>% 
    select(
      WARD16, DIV16, district,
      high_projection, low_projection, 
      primary_2011, primary_2015, 
      typical_turnout_governor, general_2018, 
      typical_turnout_da, primary_2017
    ),
  file = "turnout_projections_2019.csv",
  row.names=FALSE
)

Will this happen? We’ll find out when the Turnout Tracker returns on election day!

What’s a Ward Endorsement worth?

In 2017, about 6 points

With ballot positions decided, candidates are starting to vie for coveted ward endorsements. How many votes are they really worth?

Two years ago, I did a simplistic analysis of the Court of Common Pleas, where I found that judicial candidates received 0.9 more percent of the vote in wards where they were endorsed. In a race where candidates win with 4.3 percent of the vote, that effect is huge (and larger than even ballot position).

There were a number of caveats to that analysis: I only had endorsements from a few, systematically different wards, and I didn’t do anything to identify causality–we know that candidates do better in wards where they are endorsed, but we don’t know if the endorsements cause that increase, or if the ward leaders were endorsing candidates who would have done well there anyway.

Let’s do better.

In 2017, Max Marin at Philadelphia Weekly undertook the herculean effort of tracking down endorsements in 62 of Philadelphia’s 66 wards. Let’s use that, and do some spatial econometrics.

View code
library(tidyverse)
library(sp)
library(rgeos)
library(rgdal)
library(sf)
source("../../admin_scripts/util.R")

df_major <- safe_load("../../data/processed_data/df_major_2017_12_01.Rda")

df_major$WARD_DIVSN <- with(df_major, paste0(WARD16, DIV16))

df_major <- df_major %>%
  filter(
    election == "primary" & CANDIDATE != "Write In" & PARTY == "DEMOCRATIC"
  )
df_major <- df_major %>%
  group_by(WARD_DIVSN, OFFICE, year) %>%
  mutate(pct_vote = VOTES / sum(VOTES))

df_major <- df_major %>%
  filter(OFFICE %in% c("COUNCIL AT LARGE", "DISTRICT ATTORNEY"))


bg_17_acs <- read.csv("../../data/census/acs_2013_2017_phila_bg_race_income.csv")
bg_17_acs <- bg_17_acs %>% 
  mutate(Geo_FIPS = as.character(Geo_FIPS)) %>%
  select(
    Geo_FIPS, pop, pop_nh_white, pop_nh_black, pop_nh_asian, pop_hisp, pop_median_income_2017
  )

sp_divs <- readOGR("../../data/gis/2016/2016_Ward_Divisions.shp", verbose = FALSE)
sp_divs <- spChFIDs(sp_divs, as.character(sp_divs$WARD_DIVSN))
sp_divs <- spTransform(sp_divs, CRS("+init=EPSG:4326"))

library(tigris)
options(tigris_use_cache = TRUE)
bg_shp <- block_groups(42, 101, year = 2015)
bg_shp <- spChFIDs(bg_shp, as.character(bg_shp$GEOID))
bg_shp <- spTransform(bg_shp, CRS(proj4string(sp_divs)))

sp_divs$bg <- over(
  gCentroid(sp_divs, byid = TRUE), 
  bg_shp
)$GEOID

sp_divs@data <- sp_divs@data %>%
  left_join(bg_17_acs, by = c("bg"="Geo_FIPS"))

df_major <- df_major %>%
  left_join(sp_divs@data) %>%
  mutate(
    pct_wht = pop_nh_white / pop,
    pct_blk = pop_nh_black / pop,
    pct_asian = pop_nh_asian/ pop,
    pct_hisp = pop_hisp / pop
  )

In the 2017 DA race, no single candidate monopolized the endorsements; O’Neill led the way with 11 endorsements, largely in the Northeast.

View code
endorsements <- read_csv("da_2017_endorsements.csv")
endorsements$ward <- sprintf("%02d", endorsements$ward)

da_results <- df_major %>% 
  filter(election == "primary" & year == 2017 & OFFICE == "DISTRICT ATTORNEY") %>%
  mutate(
    last_name = gsub(
      "^.*\\s([A-Z])([A-Z]+)
View code
quot;, "\\U\\1\\L\\2", CANDIDATE, perl = TRUE ) ) %>% group_by(WARD_DIVSN) %>% mutate(total_votes = sum(VOTES)) %>% group_by() %>% mutate(pvote = VOTES / total_votes) da_results$last_name <- with( da_results, ifelse( last_name == "Neill", "O'Neill", ifelse(last_name == "Shabazz", "El-Shabazz", last_name) ) ) da_results %>% group_by(WARD16, last_name) %>% summarise(votes = sum(VOTES)) %>% group_by(WARD16) %>% mutate( ward_votes = sum(votes), pvote = votes/ ward_votes ) %>% left_join( endorsements %>% mutate(is_endorsed = TRUE), by = c("WARD16" = "ward", "last_name" = "endorsement") ) %>% mutate( is_endorsed = replace(is_endorsed, is.na(is_endorsed), FALSE) ) %>% group_by(last_name, is_endorsed) %>% summarise( pct_vote = 100 * weighted.mean(pvote, w = ward_votes), total_votes = sum(ward_votes), n_wards = n() ) %>% group_by(last_name) %>% summarise( pct_vote_overall = weighted.mean(pct_vote, w = total_votes), wards_endorsed = ifelse(any(is_endorsed), n_wards[is_endorsed], 0), turnout_endorsed = ifelse(any(is_endorsed), total_votes[is_endorsed], 0), pct_vote_notendorsed = pct_vote[!is_endorsed], pct_vote_endorsed = ifelse(any(is_endorsed), pct_vote[is_endorsed], NA) ) %>% arrange(desc(pct_vote_overall)) %>% knitr::kable( digits = 0, format = "html", format.args = list(big.mark = ","), col.names = c( "Candidate", "Citywide % of vote", "Number of ward endorsements", "Turnout in endorsed wards", "% of vote in un-endorsed wards", "% of vote in endorsed wards" ) ) 

 

Candidate Citywide % of vote Number of ward endorsements Turnout in endorsed wards % of vote in un-endorsed wards % of vote in endorsed wards
Krasner 38 9 28,700 36 46
Khan 20 8 23,485 18 32
Negrin 14 10 20,794 13 21
El-Shabazz 12 7 19,504 11 17
Untermeyer 8 9 16,200 7 17
O’Neill 6 11 21,520 4 20
Deni 2 0 0 2 NA

Krasner won by over 5,000 votes (18%), despite receiving the typical number of ward endorsements. The endorsements that he did receive came from wards with the highest turnout, but part of that is reverse causality: the places that he energized turned out big.

Naively, candidates did about 11 percentage points better in wards where they were endorsed than in wards where they weren’t. BUT. This suffers from the same lack of causal identification as the Judicial Analysis above: we don’t know if they did better because of the endorsements, or if they were just endorsed in wards where they would have done well anyway.

How can we do better? Let’s use something I noticed in last week’s post on District 7: the strength of boundaries.

The strongest ward endorsements can have visible effects in divisions just across the street from each other.

View code
wards <- readOGR("../../data/gis/2016","2016_Wards", verbose=FALSE) %>%
  spTransform(CRS(proj4string(sp_divs))) 

ggwards <- fortify(spChFIDs(wards, sprintf("%02d", wards$WARD)))

bbox <- sp_divs[substr(sp_divs$WARD_DIVSN,1,2) %in% c("10", "50", "09"),] %>% 
  gUnionCascaded() %>% 
  bbox()


bbox <- rowMeans(bbox) + 1.2 * sweep(bbox, 1, rowMeans(bbox))

polygon_in_bbox <- function(p) {
  coords <- p@Polygons[[1]]@coords
  any(
    coords[,1] > bbox[1,1] &
      coords[,1] < bbox[1,2] &
      coords[,2] > bbox[2,1] &
      coords[,2] < bbox[2,2] 
  )
}
sp_divs$in_bbox <- sapply(sp_divs@polygons, polygon_in_bbox) 

ggdivs <- fortify(spChFIDs(sp_divs, as.character(sp_divs$WARD_DIVSN)))

ggdivs <- ggdivs %>%
  left_join(
    sp_divs@data %>% select(WARD_DIVSN, in_bbox),
    by = c("id" = "WARD_DIVSN")
  ) %>%
  left_join(
    da_results %>% filter(last_name %in% c("Khan", "Krasner", "El-Shabazz")),
    by = c("id" = "WARD_DIVSN")
  )

ward_centroids <- gCentroid(wards, byid=TRUE) %>% as.data.frame()
ward_centroids$ward <- wards$WARD

ggplot(
  ggdivs %>% filter(in_bbox),
  aes(x=long, y=lat)
) +
  geom_polygon(aes(fill = 100 * pvote, group=group), color = NA) +
  geom_polygon(data = ggwards, aes(group=group), fill = NA, color = "white") +
  geom_text(data = ward_centroids, aes(x=x, y=y, label=ward), color = "white") +
  facet_wrap(~last_name) +
  scale_fill_viridis_c("% of vote") +
  theme_map_sixtysix() +
  coord_map(xlim=bbox[1,], ylim=bbox[2,]) +
  theme(
    legend.position = "bottom",
    legend.direction = "horizontal"
  ) +
  ggtitle("Percent of the Vote in Northwest Wards", "2017 DA Race")

plot of chunk map
Wards 10 and 50 endorsed Krasner, Ward 9 endorsed Khan, and Ward 22 endorsed El-Shabazz. You can immediately see the strength of 10 and 50’s endorsements: Krasner did better in divisions inside the boundary of 10 and 50 than he did just across the street. Same for 9, maybe, where Khan did well. And El-Shabazz did better in 22, though there isn’t an obvious boundary effect.

I’ll use this intuition to measure the effect across all boundaries in the whole city. To isolate the causal effect of the wards, I’ll limit the analysis to only compare divisions that are across the street from each other but happen to be divided by a ward boundary, and where different candidates were endorsed. This will ensure that we’re comparing divisions apples-to-apples, where the only thing that’s different is the ward endorsement.

I’ll go one step farther, and control for the census demographics of the block groups that the division sits in, in case a ward boundary happens to also serve as an emergent boundary (dissertation plug). I measure how each candidate’s vote correlated with the race and ethnicity of the neighborhood and subtract that out, leaving a measure of how much better or worse that candidate did than expected. It’s that “residual” that I will compare across boundaries.

View code
da_fit <- lm(
  pvote ~
    CANDIDATE * pct_wht + 
    CANDIDATE * pct_blk + 
    CANDIDATE * pct_hisp,
    # CANDIDATE * log(pop_median_income_2017),
  data = da_results
)

da_results$predicted <- predict(da_fit, newdata = da_results)
da_results$resid <- with(da_results, pvote - predicted)

neighbors <- st_intersection(st_as_sf(sp_divs), st_as_sf(sp_divs))
neighbors <- neighbors %>%
  filter(WARD != WARD.1)

neighbors <- neighbors %>%
  mutate(geometry_type = st_geometry_type(geometry)) %>%
  filter(!geometry_type %in% c("POINT", "MULTIPOINT"))


neighbors <- neighbors %>% 
  mutate(
    WARD.0 = sprintf("%02d", asnum(WARD)),
    WARD.1 = sprintf("%02d", asnum(WARD.1))
  ) %>%
  left_join(
    endorsements %>% rename(endorsement.0 = endorsement),
    by = c("WARD.0" = "ward")
  ) %>%
  left_join(
    endorsements %>% rename(endorsement.1 = endorsement),
    by = c("WARD.1" = "ward")
  ) 

neighbors <- neighbors %>% 
  left_join(
    da_results %>% 
      select(WARD_DIVSN, last_name, total_votes, pvote, resid) %>%
      rename(total_votes.0 = total_votes, pvote.0 = pvote, resid.0 = resid),
    by = c("WARD_DIVSN" = "WARD_DIVSN", "endorsement.0" = "last_name")
  ) %>% 
  left_join(
    da_results %>% 
      select(WARD_DIVSN, last_name, total_votes, pvote, resid) %>%
      rename(total_votes.1 = total_votes, pvote.1 = pvote, resid.1 = resid),
    by = c("WARD_DIVSN.1" = "WARD_DIVSN", "endorsement.0" = "last_name")
  )

To correctly measure wards’ individual strength, I fit a random effects model, which simultaneously estimates the average effect of all wards’ endorsements and how much each individual ward varies from that.

View code
library(lme4)

df0 <- neighbors %>% filter(endorsement.0 != endorsement.1)

fit_lmer <- function(neighbor_df){
  re_fit <- lmer(
    resid.0 - resid.1 ~ (1 | WARD.0),
    data = neighbor_df,
    weights = neighbor_df %>%
      with(pmin(total_votes.0, total_votes.1))
  )

  re <- ranef(re_fit)$WARD.0 
  re <- re %>%
    mutate(
      ward = row.names(re),
      effect = re_fit@beta + `(Intercept)`
    )

  return(
    list(
      fit = re_fit,
      re = re
    )
  )
}

fit_baseline <- fit_lmer(df0)

n_boot <- 200
bs_list <- vector(mode = "list", length = n_boot)
for(b in 1:n_boot){

  sample_divs = sample(unique(df0$WARD_DIVSN), replace = TRUE)

  #if(b %% floor(n_boot / 10) == 0) print(b)
  df_samp <- data.frame(WARD_DIVSN = sample_divs) %>% left_join(df0)
  bs_fit <- fit_lmer(df_samp)
  bs_list[[b]] <- bs_fit
}

fixef_ci <- quantile(
  sapply(bs_list, function(x) fixef(x$fit)),
  c(0.025, 0.975)
)
cat(paste0(
  "Average Effect of a Ward Endorsement:\n",
  sprintf(
    "%0.1f (%0.1f, %0.1f)",
    fixef(fit_baseline$fit)["(Intercept)"] * 100,
    fixef_ci[1] * 100,
    fixef_ci[2] * 100
  )
))
## Average Effect of a Ward Endorsement:
## 5.8 (5.0, 6.9)

The average Ward endorsement was worth 5.8 percentage points in the 2017 DA race. This is about half of the 11 percentage point gap we saw in the naive analysis above; it turns out the other half was because of wards endorsing candidates that the voters already supported.

But some wards are much more important than others.

How does each ward’s endorsement stack up? The table below sorts the wards by order of the vote effect, which is the percentage effect of the endorsement times the 2017 primary turnout.

View code
ranef_ci <- bind_rows(
  lapply(bs_list, function(x) x$re), 
  .id = "sim"
) %>%
  group_by(ward) %>%
  summarise(
    p025 = quantile(effect, 0.025),
    p975 = quantile(effect, 0.975)
  )

fit_baseline$re %>% 
  select(ward, effect) %>% 
  left_join(ranef_ci) %>%
  mutate(
    ci = sprintf("(%0.1f, %0.1f)", 100 * p025, 100*p975)
  ) %>%
  left_join(
    da_results %>% 
      group_by(WARD16, last_name) %>%
      summarise(
        pvote = 100 * weighted.mean(pvote, w = total_votes),
        total_votes = sum(total_votes)
      ) %>%
      inner_join(
        endorsements, 
        by = c("WARD16" = "ward", "last_name" = "endorsement")
      ),
    by = c("ward" = "WARD16")
  ) %>%
  mutate(
    pvote = round(pvote, 0),
    effect = round(100 * effect, 0),
    vote_effect = round(effect/100 * total_votes)
  ) %>%
  rename(endorsement = last_name) %>%
  select(ward, endorsement, pvote, effect, ci, total_votes, vote_effect) %>%
  arrange(desc(vote_effect)) %>% 
  DT::datatable(
    rownames=FALSE,
    colnames = c("Ward", "Endorsee", "% of Vote in Ward", "Endorsement Effect at Boundary","CI", "Ward Votes", "Vote Effect of Endorsement")
  )
Ward Endorsee % of Vote in Ward Endorsement Effect at Boundary CI Ward Votes Vote Effect of Endorsement
10 Krasner 50 14 (8.0, 19.5) 3,719 521
09 Khan 37 12 (6.7, 17.5) 4,264 512
30 Khan 39 14 (8.5, 17.9) 3,403 476
52 Negrin 17 10 (1.1, 20.0) 3,768 377
36 Negrin 18 9 (4.8, 13.6) 3,932 354
56 Untermeyer 26 14 (9.9, 19.5) 2,346 328
50 Krasner 56 6 (-0.7, 15.6) 5,094 306
61 El-Shabazz 24 12 (4.7, 19.3) 2,547 306
40 O’Neill 15 8 (4.8, 14.3) 3,591 287
42 Krasner 45 20 (14.6, 25.2) 1,270 254
38 Negrin 29 10 (3.3, 21.0) 2,507 251
01 O’Neill 13 7 (3.7, 9.1) 2,954 207
03 Untermeyer 22 8 (3.0, 14.3) 2,312 185
63 O’Neill 24 8 (2.4, 16.0) 1,920 154
19 Negrin 50 26 (16.4, 40.6) 589 153
57 O’Neill 26 8 (4.5, 13.0) 1,719 138
65 O’Neill 26 8 (5.9, 11.0) 1,644 132
23 O’Neill 20 10 (5.2, 18.7) 1,284 128
05 Khan 30 2 (-2.5, 7.2) 5,927 119
60 Negrin 15 5 (2.0, 8.2) 2,350 118
07 Negrin 55 21 (6.2, 36.9) 548 115
21 Khan 34 2 (-3.3, 8.7) 5,383 108
31 Khan 23 5 (0.4, 9.0) 2,076 104
24 Untermeyer 11 7 (4.4, 11.0) 1,437 101
51 Untermeyer 13 4 (1.8, 9.8) 2,386 95
46 El-Shabazz 9 2 (-0.9, 5.3) 4,515 90
16 Untermeyer 21 9 (1.7, 13.0) 965 87
12 Krasner 42 3 (-0.7, 7.8) 2,627 79
27 Krasner 70 4 (-3.2, 11.4) 1,978 79
41 Khan 24 7 (3.9, 11.0) 975 68
48 Untermeyer 13 4 (1.8, 7.8) 1,623 65
64 O’Neill 22 7 (-0.8, 13.8) 795 56
58 Untermeyer 15 2 (-6.2, 7.2) 2,606 52
14 Negrin 12 5 (2.8, 7.9) 986 49
34 Krasner 34 1 (-3.6, 5.2) 4,900 49
06 Krasner 47 3 (-10.0, 10.1) 1,605 48
39 O’Neill 25 1 (-1.3, 5.9) 4,462 45
43 Negrin 23 4 (-0.1, 8.4) 1,091 44
25 Untermeyer 14 5 (-1.5, 11.7) 801 40
55 O’Neill 18 3 (-2.8, 7.9) 1,305 39
54 O’Neill 11 4 (0.8, 6.5) 720 29
62 O’Neill 23 2 (-1.8, 5.8) 1,126 23
45 Khan 22 2 (-1.6, 5.5) 891 18
32 El-Shabazz 27 1 (-3.3, 6.7) 1,727 17
33 Khan 26 3 (-1.4, 9.3) 566 17
35 Untermeyer 11 1 (-1.9, 5.6) 1,724 17
04 El-Shabazz 31 0 (-3.1, 4.5) 2,110 0
44 Krasner 37 0 (-5.4, 7.7) 1,416 0
47 El-Shabazz 15 -1 (-5.0, 5.9) 749 -7
29 Negrin 23 -2 (-5.5, 4.2) 1,331 -27
15 Negrin 18 -1 (-5.1, 3.4) 3,692 -37
49 El-Shabazz 19 -3 (-7.1, 0.3) 2,348 -70
22 El-Shabazz 13 -2 (-4.5, 1.3) 5,508 -110
08 Krasner 42 -3 (-6.8, 0.6) 6,091 -183

The most important ward in 2017 was Ward 10, which gave Krasner a 14 percentage point boost on a turnout of 3,719, meaning an estimated bump of 507 votes. (The exact order of the rankings has a lot of uncertainty. Don’t take them as gospel.) Those three Northwest wards we looked at above, 10, 9 and 50, were all in the top seven, with 10 and 9 making up first and second place, largely on the back of their high turnout.

What this means for May

This analysis is specific to the 2017 DA race in a number of ways. I expect ward endorsements to have more importance in low-information races, and all of the races this time around–City Council At Large, Judicial, and Commissioner–will be lower-information than the 2017 DA.

Consider the simplistic analysis I did for the 2017 Court of Common Please. That analysis found that endorsed candidates performed 0.9 percentage points better, in a race that took 4.3% of the vote to win. That estimate is the analog to the 11 point DA effect in the first table. We found that half of the 11 DA points was actually causal, so 0.45 points is a naive guess of the effect in judicial races.

But there are two more changes. First, taking half of the effect is almost certainly too conservative for judges. There are few pre-existing preferences among voters, so much less of that correlation will be “wards endorsing candidates that are already popular”. The causal part will be higher.

But second, the wards that I had data for in that analysis are all the wards with the strongest endorsement effect in this one: 9, 30, 52, and 50 were all among the 18 wards I had data for. So that estimate might be higher, too, than if we had data for every ward.

We end up in between. The Ward endorsements–especially in the top wards on the chart–are effective but not decisive. They are powerful enough that they likely decide close judicial races, but not enough to have changed 2017’s DA race.

Appendix: Ward Map

View code
ggplot(ggwards, aes(x = long, y=lat)) +
  geom_polygon(aes(group=group), fill = strong_green, color = "white") +
  geom_text(data = ward_centroids, aes(x=x, y=y, label=ward), color = "white") +
  theme_map_sixtysix() +
  coord_map() +
  ggtitle("Philadelphia's Wards")

plot of chunk ward_map