Mayoral supporters and their votes for Council At-Large

Jonathan Tannen, Sixty Six Wards
Seth Bluestein, Philadelphia City Commissioner

Today, Sixty Six Wards is partnering with Philadelphia City Commissioner Seth Bluestein to look at individual voter patterns in the 2023 Democratic Primary.

The 2023 Democratic Primary saw mayoral candidates running in distinct lanes (Sixty Six Wards’ recap, preview). Parker won handily–in retrospect–as Rhynhart and Gym came in distant second and third. In Council, all of the winners were endorsed by the Democratic City Committee, with Santamoor, McIllmurray and Almirón–in 6th, 7th, and 8th–also apparently splitting various non-machine coalitions.

In February, Sixty-Six Wards teamed up with Commissioner Bluestein to look beyond division-aggregated data at individual voter level patterns for correlations and diversity within divisions. We’re doing it again.

The Commissioner’s office has generated that dataset with anonymized, aggregated counts of candidate combinations at the Division level by vote mode–election day, mail-in, provisional–for the 2023 primary.

Rather than a gigantic piece like last time, we will provide a series of short pieces with interesting insights from the data.

How did Mayoral supporters vote for Council?

Today, we examine how each mayoral candidate’s supporters voted for council. Among Divisions, there were strong correlations between Parker and Thomas performance, or Gym and Landau. Some of that may be ecological: supporters tend to live in the same places, but might not be the same people. Just how strong is the voter-level trend?

View code
library(dplyr)
library(ggplot2)
library(tidyr)

pairs <- readr::read_csv(
  "../../data/raw_election_data/pairs_new.csv",
  locale = readr::locale(encoding = "Latin1")
)
pairs_backup <- pairs
# pairs <- pairs_backup

toplines <- readr::read_csv(
  "../../data/raw_election_data/primary_2023_combinations_toplines.csv",
  locale = readr::locale(encoding = "Latin1")
)
voters <- readr::read_csv(
  "../../data/raw_election_data/primary_2023_combinations_voters.csv"
)
histogram <- readr::read_csv(
  "../../data/raw_election_data/primary_2023_combinations_histogram.csv",
  locale = readr::locale(encoding = "Latin1")
)

toplines$office_candidate <- factor(toplines$office_candidate)

levels_df <- data.frame(
  office_candidate=levels(toplines$office_candidate)
) |>
  separate(office_candidate, into=c("office_orig", "candidate_orig"), sep="::", remove=FALSE) |>
  mutate(
    office_orig = stringr::str_trim(office_orig),
    office = stringi::stri_trans_totitle(office_orig),
    office = gsub("(Dem|Rep)$", "(\\1)", office),
    candidate_orig = stringr::str_trim(candidate_orig),
    candidate = gsub("\\s*(DEM|REP) \\([0-9]+\\)$", "", candidate_orig),
    candidate = stringi::stri_trans_totitle(stringr::str_trim(candidate)),
    candidate = gsub("\\bMc([a-z])", "Mc\\U\\1", candidate, perl=TRUE),
    candidate = gsub("\\b([Ii]+)\\b", "\\U\\1", candidate, perl=TRUE),
  )

pairs <- pairs |> filter(candidate.x != "overvote")
pairs <- pairs |> filter(candidate.y != "overvote")

pairs$office.x <- factor(pairs$office.x)
pairs$office.y <- factor(pairs$office.y)
pairs$candidate.x <- factor(pairs$candidate.x)
pairs$candidate.y <- factor(pairs$candidate.y)

levels(pairs$office.x) <- levels_df$office[match(levels(pairs$office.x), levels_df$office_orig)]
levels(pairs$office.y) <- levels_df$office[match(levels(pairs$office.y), levels_df$office_orig)]
levels(pairs$candidate.x) <- levels_df$candidate[match(levels(pairs$candidate.x), levels_df$candidate_orig)]
levels(pairs$candidate.y) <- levels_df$candidate[match(levels(pairs$candidate.y), levels_df$candidate_orig)]

testthat::expect_false(any(is.na(pairs$office.x)))
testthat::expect_false(any(is.na(pairs$office.y)))
testthat::expect_false(any(is.na(pairs$candidate.x)))
testthat::expect_false(any(is.na(pairs$candidate.y)))

histogram$office_candidate <- factor(histogram$office_candidate, levels=levels(toplines$office_candidate))
histogram$office <- levels_df$office[histogram$office_candidate]
histogram$candidate <- levels_df$candidate[histogram$office_candidate]

toplines$office <- levels_df$office[toplines$office_candidate]
toplines$candidate <- levels_df$candidate[toplines$office_candidate]

Council Results by Mayor

View code
mayor_council <- pairs |>
  filter(
    office.y == "Mayor (Dem)", 
    office.x == "Council At-Large (Dem)"
  ) |> 
  rename(mayor = candidate.y, council=candidate.x) |>
  group_by(mayor, council, Type) |>
  summarise(counts = sum(n), .groups="drop")

# Add candidate, Type pairs that are missing
mayor_council <- expand.grid(
      mayor=unique(mayor_council$mayor), 
      Type=unique(mayor_council$Type),
      council=unique(mayor_council$council)
    ) |>
  left_join(mayor_council) |>
  mutate(counts = replace_na(counts, 0)) |>
  left_join(
    toplines |> 
      filter(office == "Mayor (Dem)") |> 
      group_by(candidate, Type) |> 
      summarise(topline_votes=sum(votes), .groups="drop"), 
    by=c("mayor" = "candidate", "Type"="Type")
  ) |>
  mutate(pvote = counts / topline_votes)

get_order <- function(office){
  toplines |>
    filter(office == !!office) |>
    group_by(candidate) |>
    summarise(votes = sum(votes), .groups="drop") |>
    arrange(desc(votes)) |>
    with(candidate)
}

council_order <- get_order("Council At-Large (Dem)")
mayor_order <- get_order("Mayor (Dem)")

mayor_council <- mayor_council |>
  mutate(
    council = factor(council, levels=council_order),
    mayor = factor(mayor, levels=mayor_order)
  )

ggplot(
  mayor_council |> 
    group_by(mayor, council) |> 
    summarise(topline_votes=sum(topline_votes), counts=sum(counts)),
  aes(x=council, y=100*counts/topline_votes)
) + 
  geom_bar(stat="identity") +
  facet_wrap(~mayor) +
  theme_minimal() %+replace% 
  theme(axis.text.x = element_text(angle=90, vjust=0.5, hjust=1, size=5)) +
  labs(
    title="Votes for Council At-Large by Mayoral Vote (Dem)",
    x=NULL,
    y="Percent of Mayoral voters who cast\nvote for Council candidate"
  ) 

Among Parker voters, the five eventual topline winners were on top. But the order was starkly different: third-place Rue Landau finished a distant fifth among this group. Conversely, Landau was the highest vote-getter among Gym voters, and among Rhynhart voters was on par with Thomas and Gilmore-Richardson. There are a few other noticeable peaks in the plot above: Santamoor and Itzkowitz received 36% and 25% of votes from Rhynhart voters, and McIllmurray and Almirón whopping 48% and 46% from Gym voters.

Amusingly, voters who wrote in choices for Mayor were vastly more likely to write in for council, too.

Those patterns didn’t change across vote mode, with Election Day voters and Mail-In voters showing similar preferences (the plots below show only the top four mayoral candidates, for sanity).

View code
ggplot(
  mayor_council |> 
    filter(mayor %in% c("Cherelle L Parker", "Rebecca Rhynhart", "Helen Gym", "Allan Domb")),
  aes(x=council, y=100*counts/topline_votes)
) + 
  geom_bar(stat="identity") +
  facet_grid(Type~mayor) +
  theme_minimal() %+replace% 
  theme(axis.text.x = element_text(angle=90, vjust=0.5, hjust=1, size=5)) +
  labs(
    title="Votes for Council At-Large by Mayoral Vote and Vote Type",
    x=NULL,
    y="Percent of Mayoral voters who cast\nvote for Council candidate"
  ) 

Notice that the average heights of the bars are different between facets; some mayoral candidates’ supporters cast more votes for Council. The heights of the bars would sum to 500 if every voter used all five votes, but only 400 if they used on average four, 300 if three, etc.

View code
mayor_council |>
  group_by(mayor, Type, topline_votes) |>
  summarise(counts = sum(counts), .groups="drop") |>
  mutate(votes_per_voter = counts/topline_votes) |>
  ggplot(
    aes(x=mayor, y=votes_per_voter)
  ) +
  geom_bar(stat="identity") +
  facet_grid(Type~.) +
  theme_minimal() %+replace% 
  theme(axis.text.x = element_text(angle=60, vjust=1, hjust=1)) +
  labs(
    title="Council At-Large votes per voter, by Mayoral choice",
    x=NULL,
    y="Average number of votes cast\nfor Council At-Large"
  )

Among Election Day voters, Gym and Rhynhart supporters cast 3.4 and 3.3 votes for Council At-Large, respectively. Parker supporters cast 2.7, and the rest of voters about 2.5. That difference vanished among Mail-In voters: all candidates’ supporters cast between 3.6 and 4.1 At-Large votes. An obvious explanation is that mail-in voters have time to look up down-ballot candidates with the ballot in hand that Election Day voters do not.

Suppose we recreated the above plot at the Division level: categorize Divisions by which mayoral candidate won, and calculate each council candidate’s percent (we’ll use total votes for Mayor as the denominator). The patterns are reasonably similar to the person-level results.

View code
div_mayor <- toplines |> filter(office == "Mayor (Dem)") |>
  group_by(Division, candidate) |> 
  summarise(votes = sum(votes)) |>
  group_by(Division) |>
  summarise(
    winner = candidate[which.max(votes)],
    pvote = votes[which.max(votes)] / sum(votes),
    mayoral_votes = sum(votes)
  ) |>
  mutate(mayor = factor(winner, levels=mayor_order))

div_council <- toplines |> filter(office == "Council At-Large (Dem)") |>
  group_by(Division, candidate) |> 
  summarise(votes = sum(votes)) |>
  mutate(council = factor(candidate, levels=council_order))

div_council |> 
  left_join(div_mayor, by="Division") |>
  group_by(mayor, council) |>
  summarise(
    votes = sum(votes),
    mayoral_votes = sum(mayoral_votes)
  ) |>
  ggplot(
    aes(x=council, y=100*votes / mayoral_votes)
  ) +
  geom_bar(stat="identity") +
  facet_wrap(~mayor) +
  theme_minimal() %+replace% 
  theme(axis.text.x = element_text(angle=90, vjust=0.5, hjust=1, size=5)) +
  labs(
    title="Votes for Council At-Large by Division's Mayoral Winner",
    x=NULL,
    y="Percent (Votes / Total votes for office of Mayor)"
  ) 

There are some interesting, small differences between the plots: Rhynhart Divisions supported McIllmurray over Harrity by 3.2pp, but Rhynhart voters supported Harrity over McIllmurray by 2pp. This is due to ecological correlations: Rhynhart voters live disproportionately in Divisions where the other voters were more likely to support McIllmurray (presumably Gym voters). Similarly, Santamoor beat Harrity by 3.9pp in Gym Divisions, but only by 0.6pp among Gym voters.

Regression Analysis

A form of ecological effects could permeate even the voter-level results above. Maybe Rhynhart voters came from Divisions more likely to support Santamoor, but within each Division they were just like the others. We can solve this by fitting a regression, using Division fixed effects. This allows us to say, for example, that within a given Division, Cherelle Parker supporters were on average 3.2 percentage points more likely to support Jim Harrity, and less likely to support Amanda McIllmurray and Rue Landau (8.8 and 7.9pp, respectively).

View code
mayor_council_div <- toplines |> 
    filter(office == "Mayor (Dem)") |>
    rename(voters=votes, mayor=candidate) |>
    select(Type, Division, mayor, voters) |>
    # Need this cross join in case there are zero votes for a council candidate.
    cross_join(
        data.frame(council=unique(mayor_council$council))
    ) |>
    left_join(
      pairs |> 
        filter(
          office.y == "Mayor (Dem)", 
          office.x == "Council At-Large (Dem)"
        ) |>
        rename(mayor = candidate.y, council=candidate.x, votes = n) |>
        select(mayor, council, Type, Division, votes),
      by=c("mayor", "council", "Type", "Division")
    ) |>
  mutate(votes=replace_na(votes, 0)) |>
  group_by(Division, mayor, council) |>
  summarise(
    voters=sum(voters),
    votes=sum(votes)
  )

RERUN <- FALSE

library(purrr)

if(RERUN){
  df_cand <- mayor_council_div |>
    ungroup() |>
    filter(council != "Write-In") |>
    # filter(council == !!council_candidate) |>
    cross_join(data.frame(vote_for=c(0,1))) |>
    mutate(votes = ifelse(vote_for==1, votes, voters-votes))
  fits <- list()
  
  for(council in unique(df_cand$council)){
    print(council)
    if(!council %in% names(fits)){
      fits[[council]] <- lm(
        vote_for ~ Division + mayor, 
        w=votes,
        data=df_cand |> filter(council==!!council)
      )
    }
  }  
  coefs_full <- lapply(fits, broom::tidy) |> bind_rows(.id="council")
  saveRDS(coefs_full, file="coefs_full.RDS")
} else {
  coefs_full <- readRDS("coefs_full.RDS")
}

mayor_coef <- coefs_full |> 
  filter(substr(term, 1,5) == 'mayor') |>
  mutate(mayor = gsub("^mayor", "", term))  |>
  select(council, mayor, estimate, std.error) |>
  bind_rows(
    data.frame(
      council = council_order,
      mayor = "Allan Domb", # Reference mayor
      estimate = 0,
      std.error = NA
    )
  ) |>
  left_join(
    toplines |> filter(office == "Mayor (Dem)") |>
      group_by(candidate) |>
      summarise(total_votes = sum(votes)),
    by=c("mayor"="candidate")
  ) |>
  group_by(council) |>
  mutate(weighted_coef = estimate - weighted.mean(estimate, w=total_votes))|>
  mutate(
    council = factor(council, levels=council_order),
    mayor = factor(mayor, levels=mayor_order)
  ) 


ggplot(
  mayor_coef,
  aes(x=council, y=weighted_coef)
) +
  geom_bar(stat='identity') +
  facet_wrap(~mayor) +
  theme_minimal() %+replace% 
  theme(axis.text.x = element_text(angle=90, vjust=0.5, hjust=1, size=5)) +
  labs(
    title="Mayoral supporters' relative vote for council",
    subtitle="Coefficient of mayoral support on council votes, controlling for Division fixed effects",
    x=NULL,
    y="Coefficient of Mayoral Support on Council Vote"
  )

Within a Division, Gym supporters were vastly more likely to support Thomas, Gilmore-Richardson, Landua, McIllmurray, and Almirón, and Rhynhart supporters more likely to support Santamoor, Itzkowitz, and then Gilmore-Richardson, Landau, and Ahmad.

Parker voters were relatively similar to their broader Divisions, but less likely to support Landau, McIllmurray, and Almirón.

Supporters of all mayoral candidates from Domb on were much less likely to support the top four council candidates–Thomas, Gilmore Richardson, Landau, and Ahmad–than supporters of the top three mayoral candidates.

Mayor 2023: Back of the Envelope, Part 2

As the Mayor’s race heats up, I’m doing a series establishing some baseline numbers. What follows are simplistic calculations using reasonable assumptions. Welcome to the Back of the Envelope. See Part 1 here.

Will candidates split the vote?

In Part 1, I argued that Philadelphia’s votes will probably be constituted by 35% from the Black Voter wards, 30% from Wealthy Progressive wards, 25% from White Moderate wards, and 10% from Hispanic Voter wards.

This means no single bloc can win the election on its own, and the winner will need to dominate at least one bloc, and do well enough in the others.

The question on my mind is whether candidates could run for the same bloc, and split the vote. With 10 candidates announced, 20% of the vote could be enough to win. If three candidates each try to consolidate the Black Voter divisions, or the Progressive divisions, could a single candidate optimize for the White Moderate divisions and win? And what neighborhoods will candidates presumably do best in?

Below, I consider candidates’ past elections to guess which blocs they’ll be targetting.

View code
library(tidyverse)
library(sf)

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


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

df_major_type <- readRDS("../../data/processed_data/df_major_type_20230116.Rds")
df_major <- df_major_type %>%
  group_by(office, candidate, party, warddiv, year, election_type, district, ward, is_topline_office) %>%
  summarise(votes = sum(votes))

df_major <- df_major %>% 
  group_by(year, election_type, office, district, warddiv) %>%
  mutate(pvote = votes / sum(votes)) %>%
  ungroup()

topline_votes <- df_major %>% 
  filter(is_topline_office) %>%
  group_by(election_type, year) %>%
  summarise(votes = sum(votes)) %>%
  mutate(
    year = asnum(year),
    cycle = case_when(
      year %% 4 == 0 ~ "President",
      year %% 4 == 1 ~ "District Attorney",
      year %% 4 == 2 ~ "Governor",
      year %% 4 == 3 ~ "Mayor"
    )
  )

cycle_colors <- c("President" = strong_red, "Mayor" = strong_blue, "District Attorney" = strong_green, "Governor" = strong_orange)

office_votes = df_major %>%
  group_by(
    year, warddiv, election_type, office, district
  ) %>%
  summarise(total_votes = sum(votes))

format_name <- function(x){
  x <- stringr::str_to_lower(x)
  x <- gsub("(^|\\s)([a-z])", "\\1\\U\\2", x, perl = TRUE)
  x
}
divs <- st_read("../../data/gis/warddivs/202011/Political_Divisions.shp") %>%
  mutate(warddiv = pretty_div(DIVISION_N))source("../../data/prep_data/div_svd_time_util.R")
div_cat_fn <- readRDS("../../data/processed_data/svd_time_20230116.RDS")

div_cats <- div_cat_fn %>% get_row_cats(2017) %>% rename(warddiv = row_id)

cats <- c(
  "Black Voters",
  "Wealthy Progressives",
  "Hispanic Voters",
  "White Moderates"
)

cat_colors <- c(light_blue, light_red, light_orange, light_green)
names(cat_colors) <- cats

What blocs will each candidate target?

For each candidate, let’s look at some relevant elections. First, the At Large Council candidates, for whom we have the best, city-wide, reference elections.

View code
relevant_elections <- tribble(
  ~candidate, ~year, ~election_type, ~office,
  "ALLAN DOMB", 2015, "primary", "COUNCIL AT LARGE",
  "ALLAN DOMB", 2019, "primary", "COUNCIL AT LARGE",
  "DEREK S GREEN", 2015, "primary", "COUNCIL AT LARGE",
  "DEREK S GREEN", 2019, "primary", "COUNCIL AT LARGE",
  "HELEN GYM", 2015, "primary", "COUNCIL AT LARGE",
  "HELEN GYM", 2019, "primary", "COUNCIL AT LARGE",
  "REBECCA RHYNHART", 2017, "primary", "CITY CONTROLLER",
  # "REBECCA RHYNHART", 2021, "primary", "CITY CONTROLLER",
) %>% mutate(year = as.character(year))

comp_results <- df_major %>% 
  inner_join(relevant_elections) %>%
  left_join(office_votes) %>%
  group_by(candidate, year, election_type, office) %>%
  mutate(
    pvote_total = sum(votes, na.rm=TRUE) / sum(total_votes, na.rm=TRUE)
  ) %>%
  ungroup()

cat_results <- comp_results %>% 
  left_join(div_cats %>% select(-year)) %>%
  group_by(year, cat, candidate, election_type, office) %>%
  summarise(pvote = sum(votes) / sum(total_votes))


ggplot(
  divs %>% 
    left_join(comp_results) %>%
    filter(office == "COUNCIL AT LARGE")
) +
  geom_sf(aes(fill=100 * pmin(pvote, 0.20)), color=NA) +
  facet_grid(format_name(candidate) ~ year) +
  scale_fill_viridis_c("% of Votes") +
  theme_map_sixtysix() %+replace%
  theme(legend.position = "right") +
  labs(
    title="City Council At Large Results"
  )

In 2015, Domb did best in the River Wards, the Northeast, South Philly, and immediate Center City. Derek Green did best among Black divisions in West, and North Philly. Helen Gym did best in the ring around Center City and Chestnut Hill / Mount Airy.

In 2019, we see the effects of incumbency. Gym led the pack by dominating the Wealthy Progressive and Black Wards from the first column, Green did well enough everywhere despite losing his top ballot position, and Domb improved across Black Wards thanks to incumbency and the party endorsement.

View code
ggplot(
  cat_results %>% filter(office == "COUNCIL AT LARGE"),
  aes(x=cat, y=100*pvote)
) +
  geom_bar(aes(fill=cat), color=NA, stat="identity") +
  facet_grid(year ~ format_name(candidate)) +
  scale_fill_manual(values=cat_colors, guide=FALSE) +
  theme_sixtysix() %+replace%
  theme(axis.text.x = element_text(angle=45, hjust = 1, vjust=1)) +
  labs(
    title="City Council At Large by Bloc",
    x=NULL,
    y = "% of Votes"
  )

Rhynhart won a competitive primary in 2017, albeit in the lower profile City Controller race. She did so by cleaning up in the Wealthy Progressive wards around Center City, though did beat 50% in the Black and Hispanic Voter blocs.

View code
ggplot(
  divs %>% 
    left_join(comp_results) %>%
    filter(office == "CITY CONTROLLER")
) +
  geom_sf(aes(fill=100 * pvote), color=NA) +
  # facet_grid(format_name(candidate) ~ year) +
  scale_fill_viridis_c("% of Votes") +
  theme_map_sixtysix() %+replace%
  theme(legend.position = "right") +
  labs(
    title="Rebecca Rhynhart in the 2017 Primary"
  )

View code
ggplot(
  cat_results %>% filter(office == "CITY CONTROLLER"),
  aes(x=cat, y=100*pvote)
) +
  geom_bar(aes(fill=cat), color=NA, stat="identity") +
  # facet_grid(year ~ format_name(candidate)) +
  scale_fill_manual(values=cat_colors, guide=FALSE) +
  theme_sixtysix() %+replace%
  theme(axis.text.x = element_text(angle=45, hjust = 1, vjust=1)) +
  labs(
    title="Rebecca Rhynhart's 2017 Primary by Bloc",
    x=NULL,
    y = "% of Votes"
  )

Three candidates come from districted seats: Cherelle Parker, Maria Quiñones-Sánchez, and Amen Brown. We don’t have city-wide elections for them, but can reasonably guess that they’ll do well in the blocs that their districts constitute.

View code
district_elections <- tribble(
  ~candidate, ~year, ~election_type, ~office,
  "CHERELLE L PARKER", 2019, "primary", "DISTRICT COUNCIL",
  "MARIA QUIÑONES SÁNCHEZ", 2019, "primary", "DISTRICT COUNCIL",
  "AMEN BROWN", 2022, "primary", "REPRESENTATIVE IN THE GENERAL ASSEMBLY"
) %>% mutate(year = as.character(year))

districts <- 
  divs %>% inner_join(
    df_major %>% 
    inner_join(district_elections) %>%
    select(office, district, warddiv, candidate)
  ) %>% group_by(office, district, candidate) %>% 
  summarise(
    geometry=st_union(geometry)
  )
districts <- nngeo::st_remove_holes(districts)


phila_whole <- st_read("../../data/gis/warddivs/201911/Political_Wards.shp") %>% 
  st_union()ggplot(phila_whole) + 
  geom_sf(color=NA) + 
  geom_sf(data=districts, aes(fill = candidate), color=NA) +
  geom_sf_text(
    data=districts %>% mutate(geom = st_centroid(geom)),
    aes(label = sprintf(
      "%s\n(%s-%s)", 
      format_name(candidate), 
      case_when(office == "DISTRICT COUNCIL" ~ "Council", TRUE ~ "PA"),
      district
    )),
    fontface="bold"
  ) +
  scale_fill_discrete(guide=F) +
  theme_map_sixtysix() +
  labs(
    title="Mayoral Candidates' Districts"
  )

Parker and Brown represent predominantly-Black districts, Quiñones-Sánchez a predominantly Hispanic one.

That leaves Warren Bloom, Jeff Brown, and Jimmy DeLeon as candidates without instructive elections. Brown is spending a lot of money, and running on a platform that I expect to do better in the White Moderate bloc. Bloom has run six times before without making any ripples, and DeLeon is a… longshot.

The crowded lanes

Where does that leave us? We have three candidates who have done best in Black wards, Parker, Green, and Amen Brown; two candidates who have done best in Wealthy Progressive wards, Gym and Rhynhart; two we can expect to do best in White Moderate wards, Domb and Jeff Brown; one who’ll do best in the Hispanic bloc, Quiñones-Sánchez; and two without an electoral history, Bloom and Deleon.

Obviously these candidates won’t split the blocs evenly, so we can’t just divide the vote by the number of candidates. And winning will require doing well enough in other blocs to hit a winning profile. The candidates from At Large council–Green, Gym, and Domb–probably enter with the best name recognition, and might have higher baselines even in their worse blocs.

That was clear in the last two competitive mayoral primaries of 2007 and 2015. In 2007, Nutter dominated the Wealthy Progressive bloc, but also won the Black Voter bloc and broke 20% in White Moderate and Hispanic Voter blocs. In 2015, Kenney did best in the White Moderate and Wealthy Progressive blocs, but actually won in all of them. Both Kenney and Nutter were City Councilmembers.

View code
df_major %>% 
  filter(
    office == "MAYOR",
    election_type == "primary",
    party == "DEMOCRATIC",
    year %in% c(2015, 2007)
  ) %>% 
  left_join(div_cats %>% select(-year)) %>%
  group_by(cat, year, candidate) %>%
  summarise(votes=sum(votes, na.rm=TRUE)) %>%
  group_by(cat, year) %>%
  mutate(pvote = votes / sum(votes)) %>%
  ggplot(
    aes(x = cat, y=100*pvote) 
  ) +
  geom_text(aes(label = format_name(candidate))) +
  facet_grid(year ~.) +
  geom_hline(yintercept=0)+
  theme_bw() +
  labs(
    x=NULL,
    y="% of Votes",
    title="Results in each bloc",
    subtitle="2007 and 2015 Mayoral Primaries"
  )

With petitions due soon and the race maturing, we will probably see several candidates drop out ahead of the election. That could help a candidate consolidate their bloc. Without drop-outs, though, this election would probably prove more fractured than 2007 and 2015. But the broader point holds: the winner will probably do well in two blocs, and well-enough in the rest.