Who will win the Court of Common Pleas?

On May 21st, Philadelphia won’t just be voting for Mayor, City Council, and a few “row offices”. Besides those, we will also choose nine judges: two for the Superior Court, one for Municipal Court, and six for the Court of Common Pleas. (Really, this is just the primary. But the Common Pleas and Municipal nominees will almost certainly win in November).

I’ve spent time here before looking at the Court of Common Pleas. The court is responsible for the city’s major civil and criminal trials. Its judges are elected to ten-year terms. And we elect them by drawing out of a coffee can.

The result is that Philadelphia often elects judges who are unfit for the office. In 2015, Scott Diclaudio won; months later he would be censured for misconduct and negligence, and then get caught having given illegal donations in the DA Seth Williams probe. He was in the first position on the ballot. Lyris Younge was at the bottom of the first column that year and won. She has since been removed from Family Court for violating family rights and made headlines by evicting a full building of residents with less than a week’s notice.

I’ve looked before at the effect of ballot position on the Court’s elections: being in the first column nearly triples your votes. Today, I’ll use that model to simulate who will win in the upcoming race.

It’s easy to predict the Common Pleas Election

Predicting elections is hard, especially without surveys. When I tried it for November’s state house election, I could only make imprecise predictions, and even then had mixed results. Why would this time be any different?

The key is that voters know nothing about the race. In May, voters are selecting six Common Pleas judges from among twenty-five candidates. The median voter will know the name of exactly zero of them before they enter the booth.

This lack of knowledge means that structural components end up mattering a lot. What column your name is listed in, whether you’re endorsed in the Inquirer, or how many polling places your name is handed out on a piece of paper outside of, all dictate who will win. We can observe or guess each of those, and come up with pretty accurate predictions.

When I did this exercise two years ago, I got the number of winners from the first column, and the number endorsed by the DCC, exactly right (yes, it’s that easy).

Electing qualified judges

These races matter. Philadelphia regularly elects judges who should not be judges, granting them the authority over a courtroom that decides the city’s most important cases.

As a measure of judicial quality, I use the recommendations from the Judicial Commission of the Philadelphia Bar Association. The commission evaluates candidates by an interview, a questionnaire, and interviews with people who work with them. It then rates candidates as Recommended or Not Recommended. Usually, it recommends about 2/3 of the candidates–many more than can win–and is useful as a lower-bar measure of candidate quality. My understanding is that when a candidate isn’t recommended, there’s a significant reason, though the Commission’s exact findings are kept confidential.

The ratings are so useful that in 2015 the Philadelphia Inquirer stopped endorsing judicial candidates on its own, and began printing the Commission’s recommendations (this also makes the ratings much more important for candidates).

Recently, the Commission introduced a Highly Recommended category. Unfortunately, it’s too early to know how effective it’s been. In 2015 there were three Highly Recommended candidates, and all three won. But they didn’t do statistically significantly better than the plain old Recommended Candidates in terms of votes (albeit with just three observations). In 2017, there were no Highly Recommended candidates.

This time around, there are four Highly Recommended candidates: James Crulish, Anthony Kyriakakis, Chris Hall, and Tiffany Palmer (a fifth, Michelle Hangley, dropped out because of her unlucky ballot position). None of those four are in the first column, so this year could prove a useful measure of the Bar’s impact.

One note: candidates that do not submit questionnaires are not rated Recommended. Rather than reward the perverse incentive for candidates to not submit, I will consider candidates who have not yet submitted paperwork as Not Recommended.

Where will ballot position matter most?

When I analysed the determinants of Common Pleas voting, being in the first column nearly tripled a candidate’s votes. Endorsements from the Democratic City Committee (DCC) and the Inquirer doubled the votes (though the causal direction here is more dubious). Remember that the Inquirer has recently just adopted the Bar’s recommendations, so the importance of the Inquirer will be transferred to the Philadelphia Bar.

The ballot this year is wide. There are just four rows and seven columns. With 25 candidates vying for 6 spots, a number of later column candidates will almost certainly win.

Two years ago when I simulated the race, I did so at the city-level, ignoring neighborhood patterns. But we might see vastly disproportionate turnout in some neighborhoods, and it happens that those are the neighborhoods where recommended candidates do best. So let’s be more careful. First, how much does each determinant of candidates’ votes vary by neighborhood?

View code
library(ggplot2)
library(dplyr)
library(tidyr)
library(tibble)
library(readr)
library(forcats)

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

ballot <- read.csv("../../data/common_pleas/judicial_ballot_position.csv")
ballot$name <- tolower(ballot$name)
ballot$name <- gsub("[[:punct:]]", " ", ballot$name)
ballot$name <- trimws(ballot$name)

years <- seq(2009, 2017, 2)
dfs <- list()
for(y in years){
dfs[[as.character(y)]] <- read_csv(paste0("../../data/raw_election_data/", y, "_primary.csv")) %>% 
mutate(
year = y,
CANDIDATE = tolower(CANDIDATE),
CANDIDATE = gsub("\\s+", " ", CANDIDATE)
) %>%
filter(grepl("JUDGE OF THE COURT OF COMMON PLEAS-D", OFFICE))
print(y)
}

df <- bind_rows(dfs)

df <- df %>% 
mutate(WARD = sprintf("%02d", WARD)) %>%
group_by(WARD, year, CANDIDATE) %>% 
summarise(VOTES = sum(VOTES))

df_total <- df %>% 
group_by(year, CANDIDATE) %>% 
summarise(VOTES = sum(VOTES))

election <- data.frame(
year = c(2009, 2011, 2013, 2015, 2017),
votefor = c(7, 10, 6, 12, 9)
)

election <- election %>% left_join(
ballot %>% group_by(year) %>% 
summarise(
nrows = max(rownumber),
ncols = max(colnumber), 
ncand = n(),
n_philacomm = sum(philacommrec),
n_inq = sum(inq),
n_dcc = sum(dcc)
)
)

df_total <- df_total %>% 
left_join(election) %>%
group_by(year) %>%
arrange(desc(year), desc(VOTES)) %>%
mutate(finish = 1:n()) %>%
mutate(winner = finish <= votefor)

df_total <- df_total %>% inner_join(
ballot,
by = c("CANDIDATE" = "name", "year" = "year")
)

df_total <- df_total %>%
group_by(year) %>%
mutate(pvote = VOTES / sum(VOTES))

df_total <- df_total %>%
filter(CANDIDATE != "write in") 

prep_df_for_lm <- function(df, use_candidate=TRUE){
df <- df %>% mutate(
rownumber = fct_relevel(factor(as.character(rownumber)), "3"),
colnumber = fct_relevel(factor(as.character(colnumber)), "3"),
col1 = colnumber == 1,
col2 = colnumber == 2,
col3 = colnumber == 3,
row1 = rownumber == 1,
row2 = rownumber == 2,
is_rec = philacommrec > 0,
is_highly_rec = philacommrec==2,
inq=inq>0
)
if(use_candidate)
df <- df %>% mutate(
candidate_year = paste(CANDIDATE, year, sep="::")
)
return(df)
}

df_complemented <- df %>% 
filter(CANDIDATE != "write in") %>%
group_by(WARD) %>%
mutate(pvote = VOTES / sum(VOTES)) %>%
inner_join(
df_total %>% prep_df_for_lm(),
by = c("year", "CANDIDATE"),
suffix = c("", ".total")
) 

# fit_model <- function(df){
#   lmfit <- lm(
#     log(pvote + 0.001) ~ 
#       row1 + row2 +
#       # col1*I(votefor - nrows) + 
#       # col2*I(votefor - nrows) + 
#       # col3*I(votefor - nrows) +
#       I(gender == "F") +
#       col1 + col2 + col3 +
#       inq + dcc + 
#       is_rec + is_highly_rec +
#       factor(year),
#     data = df_total %>% prep_df_for_lm()
#   )
#   return(lmfit)
# }
# 
# lmfit <- fit_model(df_total)
# summary(lmfit)
View code
library(lme4)

## better opt: https://github.com/lme4/lme4/issues/98
library(nloptr)
defaultControl <- list(
algorithm="NLOPT_LN_BOBYQA",xtol_rel=1e-6,maxeval=1e5
)
nloptwrap2 <- function(fn,par,lower,upper,control=list(),...) {
for (n in names(defaultControl)) 
if (is.null(control[[n]])) control[[n]] <- defaultControl[[n]]
res <- nloptr(x0=par,eval_f=fn,lb=lower,ub=upper,opts=control,...)
with(res,list(par=solution,
fval=objective,
feval=iterations,
conv=if (status>0) 0 else status,
message=message))
}

rfit <- lmer(
log(pvote + 0.001) ~ 
(1 | candidate_year)+
row1 + row2 +
I(gender == "F") +
col1 + col2 +
dcc + 
is_rec + is_highly_rec +
factor(year) +
(
# row1 + row2 +
# col1*I(votefor - nrows) + 
# col2*I(votefor - nrows) + 
# col3*I(votefor - nrows) +
I(gender == "F") +
col1 + col2 + #col3 +
dcc +
is_rec + is_highly_rec 
# factor(year)
| WARD
),
df_complemented
)

ranef <- as.data.frame(ranef(rfit)$WARD) %>% 
rownames_to_column("WARD")  %>%
gather("variable", "random_effect", -WARD) %>%
mutate(
fixed_effect = fixef(rfit)[variable],
effect = random_effect + fixed_effect
)

Recommended candidates receive about 1.8 times as many votes on average, drawing almost all of that advantage from Center City and Chestnut Hill & Mount Airy. While overall we didn’t see a benefit to being Highly Recommended, in the neighborhood drill-down, we do see tentative evidence that those candidates did even better in the wealthier wards (a highly recommended candidate would receive the sum of the Recommended + Highly Recommended effects below).

View code
library(sf)

wards <- read_sf("../../data/gis/2016/2016_Wards.shp")

ward_effects <- wards %>% 
mutate(WARD = sprintf("%02d", WARD)) %>%  
left_join(
ranef,
by=c("WARD" = "WARD")
)

format_effect <- function(x){
paste0("x", round(exp(x), 1))
}

fill_min <- ward_effects %>%
filter(
variable %in% c(
"col1TRUE", "col2TRUE", "dcc", "is_recTRUE", "is_highly_recTRUE"
)
)  %>%
with(c(min(effect), max(effect)))

format_variables <- c(
is_recTRUE="Recommended",
is_highly_recTRUE="Highly Recommended",
dcc = "Dem. City Committee Endorsement",
col1TRUE = "First Column",
col2TRUE = "Second Column"
)

ward_effects$variable_name <- factor(
format_variables[ward_effects$variable],
levels = format_variables
)

ggplot(
ward_effects %>% 
filter(variable_name %in% c(format_variables[1:2]))
) + 
geom_sf(aes(fill=effect), color = NA) +
facet_wrap(~variable_name) +
scale_fill_viridis_c(
"Multiplicative\nDifference in Votes", 
labels=format_effect, 
breaks = seq(-2, 3, 0.4)
) +
theme_map_sixtysix() %+replace%
theme(legend.position="right") +
expand_limits(fill = fill_min) +
ggtitle("Recommended candidates do better\n  in wealthier wards") 

What’s going on in the other wards? The Democratic Party is especially important, especially in the traditionally-strong Black wards. (Note, I can’t identify here if that’s because they strongly adopt the party’s endorsement, or if the party endorses the candidates who would already do well). Interestingly, the party wasn’t so important in the Hispanic wards of North Philly or the Northeast.

View code
ggplot(
ward_effects %>% 
filter(variable_name %in% c(format_variables[3]))
) + 
geom_sf(aes(fill=effect), color = NA) +
facet_wrap(~variable_name) +
scale_fill_viridis_c(
"Multiplicative\nDifference in Votes", 
labels=format_effect, 
breaks = seq(-2, 3, 0.4)
) +
theme_map_sixtysix() %+replace%
theme(legend.position="right") +
expand_limits(fill = fill_min) +
ggtitle("Party-endorsed candidates\ndo better in predominantly-Black wards")

Unfortunately, all of these effects are swamped by ballot position. Candidates in the first column receive twice as many votes in every single type of ward, but especially many in lower-income wards.

View code
ggplot(
ward_effects %>% 
filter(variable_name %in% c(format_variables[4:5]))
) + 
geom_sf(aes(fill=effect), color = NA) +
facet_wrap(~variable_name) +
scale_fill_viridis_c(
"Multiplicative\nDifference in Votes", 
labels=format_effect, 
breaks = seq(-2, 3, 0.4)
) +
theme_map_sixtysix() %+replace%
theme(legend.position="right") +
expand_limits(fill = fill_min) +
ggtitle(
"First-column candidates do better everywhere", 
"Relative to third column or later"
)

Simulating the election

The task of predicting the election comes down to using these correlations, and then randomly sampling uncertainty of the correct size.

I use each candidate’s ballot position and endorsements to come up with a baseline estimate of how they’ll do in each ward. There is a lot of uncertainty for a given candidate, so I add random noise to each candidate (candidate-level effects that aren’t explained by my model have a standard deviation of about +/- 30% of their votes.)

I scale up the ward performance by my turnout projection. I’m using my high-turnout projections, which assume that the post-2016 surge continues in Center City and its ring, and will in general help recommended candidates, who do better in those wealthier wards.

View code
turnout_2019 <- read.csv(
"../turnout_2019_primary/turnout_projections_2019.csv"
) %>%
mutate(WARD = sprintf("%02d", WARD16)) %>%
group_by(WARD) %>%
summarise(
high_projection = sum(high_projection, na.rm = TRUE),
low_projection = sum(low_projection, na.rm = TRUE)
)

replace_na <- function(x, r=0) ifelse(is.na(x), r, x)

df_2019 <- ballot %>% 
filter(year == 2019) %>%
mutate(
philacommrec = replace_na(philacommrec),
dcc = replace_na(dcc),
inq = (philacommrec > 0),
year = 2017  ## fake year to trick lm
) %>%
prep_df_for_lm(use_candidate = FALSE) %>%
left_join(
expand.grid(
name = unique(ballot$name),
WARD = unique(turnout_2019$WARD)
)
) %>% left_join(turnout_2019)


## pretend it's one candidate, but then marginalize over candidates
df_2019$log_pvote <- predict(
rfit,
newdata = df_2019 %>% 
mutate(candidate_year = df_complemented$candidate_year[1])
)

df_2019 <- df_2019 %>%
mutate(pvote_prop = exp(log_pvote))

sd_cand <- sd(ranef(rfit)$candidate_year$`(Intercept)`)
simdf <- expand.grid(
sim = 1:1000,
name = unique(df_2019$name)
) %>%
mutate(cand_re = rnorm(n(), sd = sd_cand))

## https://econsultsolutions.com/simulating-the-court-of-common-pleas-election/
votes_per_voter <- 4.5

simdf <- df_2019 %>%
left_join(simdf) %>%
mutate(pvote_prop_sim = pvote_prop * exp(cand_re)) %>%
group_by(WARD, sim) %>%
mutate(pvote = pvote_prop_sim / sum(pvote_prop_sim)) %>%
group_by() %>%
mutate(votes = high_projection * votes_per_voter * pvote) %>%
group_by(sim, name) %>%
summarise(votes = sum(votes)) %>%
left_join(ballot %>% filter(year == 2019)) %>%
prep_df_for_lm(use_candidate = FALSE)

simdf <- simdf %>%
group_by(sim) %>%
mutate(
vote_rank = rank(desc(votes)),
winner = rank(vote_rank) <= 6
)

remove_na <- function(x, r=0) return(ifelse(is.na(x), r, x))

winner_df <- simdf %>% 
group_by(sim) %>%
summarise(
winners_rec = sum(is_rec * winner),
winners_highly_rec = sum(is_highly_rec * winner),
winners_col1 = sum(col1 * winner),
winners_col2 = sum(col2 * winner),
winners_col3 = sum(col3 * winner),
winners_dcc = sum(remove_na(dcc) * winner),
winners_women = sum((gender == "F") * winner)
)

Under the hood, the model has an estimate for each candidate. But I’m not totally comfortable with blasting those out (and what feedback loops that might cause), so let’s look at the high-level predictions instead.

View code
col_sim <- winner_df %>%
select(winners_col1, winners_col2) %>%
mutate(
`Third Column or later` = 6 - winners_col1 - winners_col2
) %>%
rename(
`First Column` = winners_col1,
`Second Column` = winners_col2
)

rec_sim <- winner_df %>%
select(winners_rec, winners_highly_rec, winners_dcc) %>%
mutate(
`Not Recommended` = 6 - winners_rec
) %>%
rename(
`All Recommended` = winners_rec,
`Highly Recommended` = winners_highly_rec,
`DCC Endorsed` = winners_dcc
)

gender_sim <- winner_df %>%
select(winners_women) %>%
mutate(
`Men` = 6 - winners_women
) %>%
rename(
`Women` = winners_women
)

plot_winners <- function(
sim_df, 
title, 
facet_order,
colors
){
gathered_df <- sim_df %>%
gather("facet", "n_winners") %>%
mutate(
facet = factor(
facet,
facet_order
)
) %>%
group_by(facet, n_winners) %>%
count() %>%
group_by(facet) %>%
mutate(prop = n / sum(n))

facet_lev <- levels(gathered_df$facet)
names(colors) <- facet_lev
ggplot(
gathered_df,
aes(x=n_winners)
) +
geom_bar(aes(y = prop, fill = facet), stat="identity") +
theme_sixtysix() +
expand_limits(x=c(0,7)) +
scale_x_continuous("Count of winners", breaks = 0:7) +
ylab("Proportion of simulations") +
scale_fill_manual(values = colors, guide=FALSE)+
facet_grid(facet~.) +
geom_vline(xintercept=6, linetype="dashed") +
ggtitle(title)
}

The model is really optimistic about how many Recommended candidates win, mostly because there’s only one Not Recommended candidate in the first two columns. In 66% of simulations all six winners are Recommended, and in 33% all but one are (Jon Marshall at the bottom of the first column is usually the lone Not Recommended winner).

View code
plot_winners(
rec_sim, 
"Simulations by Recommendation", 
c("Highly Recommended", "All Recommended", "Not Recommended", "DCC Endorsed"),
c(strong_blue, strong_green, strong_red, strong_grey)
)

Highly Recommended candidates do less well; we get no Highly Recommended winners in 46% of simulations, and only one in another 47%. Remember that the model doesn’t think that being Highly Recommended helps more than just regular Recommended, and this year’s candidates have bad ballot position. Their performance this year will be a barometer for the power of the Bar’s endorsements; getting two winners (let alone three or four) would be a huge achievement (and presumably good for the citizens of Philadelphia, too).

DCC endorsees win an average of 3.1 of the six seats.

Of course, the true determinant is the first column.

View code
plot_winners(
col_sim, 
"Simulations by Column Position", 
c("First Column", "Second Column", "Third Column or later"),
c(strong_purple, strong_orange, strong_grey)
)

The first column produces 2.5 winners on average; the most likely outcome is three of the first-column candidates winning (45% of simulations), the second most likely is two (40%). The second column still produces 1.0 winners on average, with the remaining 2.4 winners coming from the final five columns.

View code
wincount_df <- simdf %>% 
group_by(sim) %>%
mutate(pvote= votes/sum(votes)) %>%
filter(vote_rank == 6)

How many votes will it take to win? The average sixth-place winner wins 5.1% of the vote (remember that candidates can vote for multiple candidates). Assuming that 218,000 people vote, and an average of 4.5 candidates selected per voter, that comes out to 50,000 votes.

View code
ggplot(
wincount_df,
aes(x = pvote * 100)
) +
geom_histogram(
aes(y=stat(count) / sum(stat(count))),
boundary=5,
binwidth=0.2,
fill = strong_green
) +
ylab("Proportion of Simulations") +
xlab("Percent of vote received by sixth place") +
geom_vline(xintercept = 100 * mean(wincount_df$pvote), color = "black") +
annotate(
"text", 
label = sprintf("Mean = %.1f %%", 100 * mean(wincount_df$pvote)),
x = 100 * mean(wincount_df$pvote),
y = 0.05,
angle = 90,
vjust = 1.1
)+ 
theme_sixtysix() +
ggtitle("Win Count for Common Pleas") 

This year may be… not too bad?

In 2015, three Not Recommended candidates became ten year judges. In 2017, three more did. This year, probably at worst only one will. Why? Mostly luck; all six of those unqualified winners were in the first column, and this year seven of the eight candidates in the first two columns are Recommended.

Instead, the open question is just how qualified our judges will be. Will we stay on par with the past, which would see my model’s predicted zero or only one Highly Recommended candidate win? Or will the Bar’s Highly Recommended ratings assert themselves, and prove a bigger player this year?

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!