= votes %>%
stv group_by(DivisionID) %>%
# Take the highest count
slice_max(CountNumber, n = 1) %>%
# Calculate the margin of victory
arrange(desc(prefCount), .by_group = TRUE) %>%
mutate(margin = prefCount - lead(prefCount)) %>%
# Take the winner
slice_max(prefCount, n = 1) %>%
ungroup()
Previously, we conducted data cleaning and set ourselves up for a (hopefully) painless analysis. In this part, we’ll determine what the makeup of the lower house would be under each electorate system, based on the 2022 election results.
If you’ve arrived in medias res, a reminder that the whole series is divided into three parts:
- Part One
Will define the question, provide some overview of the different electorate systems, and clean the data. - Part Two
Conduct the analysis, and generate some preliminary results. - Part Three
Refine the analysis, and offer some interpetation.
Single Transferrable Vote
We’ll start with Single Transferable Vote, as it is the current system in use. It’s also easy - the AEC has done all the work already.
You can explore the outcomes for STV in the table below. Comparative analysis will have to wait for part 3.
First Past the Post
First past the post is also straightforward - it’s whoever won the first round of preferences. We’ll calculate a couple of other metrics here as well, firstly the margin of victory to get a sense of the fragility of the system, and also put together a tooltip, which we will use when mapping this result.
= votes %>%
fptp group_by(DivisionID) %>%
# Restrict to the first round of preferences
filter(CountNumber == 0) %>%
# Calculate the margin between first and second
arrange(desc(prefCount), .by_group = TRUE) %>%
mutate(margin = prefCount - lead(prefCount)) %>%
# Identify the person with the most votes
slice_max(prefCount, n = 1) %>%
mutate(tooltip = paste0(DivisionNm, "\n",
" ", Surname, "\n",
GivenNm, " by ", margin)) %>%
PartyNm, ungroup()
Mixed Member Proportional
This is the most complex of the three, as we are trying to map existing votes to a different electorate system. So, we’ll have to make some assumptions:
- Continue to ignore the senate, and focus solely on the lower house
- Addition of 631 list MPs, surplus2 to the current electorate MPs
- Party vote will be defined as the percentage of 1st preference votes that were not for an independent
This is probably the most objectionable - my anecdotal experience of one New Zealand election and the associated commentary is that some people may vote quite differently (including either major party) based on their relative opinion of their local candidates and the party as a whole3, as well as for strategic voting. - Independents are allowed to run, but can only win electorate seats, and don’t contribute to party vote4
New Zealand independents seem to run as a member of a single-person party, but this assumption holds in practice because they don’t receive significant proportion of the national vote and so don’t reach the 5% threshold required for list MPs.
1 This makes the ratio of list:electorate MPs to be equivalent to that used by the NZ parliament, which is 50:70.
2 Redrawing the current electorate boundaries to maintain 151 total seats is left as an exercise to the reader (or the AEC).
3 I find this a colourable explanation - all politics is local, and popular local members seemed to hold their seats by greater margins than the national swing would assume.
4 A consequence of this is all independent MPs will cause an overhang.
The composition of parliament in an MMP system is done by determining:
- What the proportion of seats in parliament that each party should receive
- The number of these seats that will be filled by electorate wins
Additional seats are allocated based on the ratio of electorate seats to the proportional seats.- If the number of proportionally allocated seats is greater than the number of electorate seats5, the party receives additional list seats
- If the number of electorate seats is greater than the number of proportionally allocated seats, then this is an overhang
In this case, party keeps all their electorate seats and gains no list seats6.
5 This is usual and expected.
6 There are a variety of different methods to deal with an overhang, this is the method used by New Zealand, and is comparatively simple.
First, a quick check to see if there are any parties that have only one member - we will count those as independents.
= votes %>%
party.size group_by(PartyAb) %>%
summarise(n = n()) %>%
arrange(n)
The next step is to determine, for each party, the:
- Number of electorate seats won
- Proportion of the party vote received
- Number of seats that they are entitled to
# Calculate the number of electorate seats
# This uses FPTP - we can reuse our above code
= votes %>%
mmp.elec group_by(DivisionID) %>%
# Restrict to the first round of preferences, and then the most votes
filter(CountNumber == 0) %>%
slice_max(prefCount, n = 1)
# Calculate the total makeup of parliament
= votes %>%
mmp # Drop the later rounds of voting, and the independents
# If there were any single-member parties, we'd drop those too
filter(!PartyAb %in% c("IND"),
== 0) %>%
CountNumber droplevels() %>%
group_by(PartyAb) %>%
# Calculate the total party vote received (and proportion)
summarise(partyVote = sum(prefCount)) %>%
mutate(partyVotePC = (partyVote / sum(partyVote)) %>%
multiply_by(100) %>%
round(digits = 2)) %>%
# Add the number of electorate seats won
# Full join adds the independents back in, without their vote count or PC
full_join(mmp.elec %>%
group_by(PartyAb) %>%
summarise(seatsElecN = n())) %>%
mutate(seatsElecN = replace_na(seatsElecN, 0),
seatsElecPC = (seatsElecN / length(levels(votes$DivisionNm))) %>%
multiply_by(100) %>%
round(digits = 2))
Now we allocate the list MPs. Exact allocation is not possible, as MPs don’t share seats7, and so some system for distributing seats to try and minimise apportionment paradoxes8. New Zealand uses the Sainte-Laguë system, which is designed to maximise proportional representation and iteratively allocates seats based on the following rule:
7 This could be interesting, though.
8 Apportionment paradoxes occur when seat allocation is unexpected given the voting behaviour of the electorate.
\[Quotient = {Votes \over {2 \times Seats + 1}}\]
Where:
- The party with the highest \(quotient\) gets the next seat
- \(Votes\) is the number of votes that party received
This doesn’t change between iterations. - \(Seats\) is the number of seats allocated by the system
This starts at 0 for all parties.
We calculate the number of MPs in our new parliament, which we could easily do by hand but instead do by piping a frankly excessive number of functions, and then calculate the quotient, which would be a pain to do by hand.
# Determine number of pre-overhang seats that we will allocate to non-independents
= votes$DivisionNm %>%
n.mp levels() %>%
length() %>%
# Multiply by the ratio of NZ list:electorate MPs
multiply_by(1 + (50/120)) %>%
ceiling() # 214 total seats: 63 list, 151 electorate
# Initial conditions
$seatsEntitledN = 0
mmp
# Determine number of entitled seats based on party vote
for(i in 1:n.mp){
= mmp %>%
mmp # Calculate quotient
mutate(quot = fn.saint_lague(votes = partyVote,
seats = seatsEntitledN),
# Drop parties that don't receive 5% of the vote, have an electorate seat, or aren't a party
quot = case_when(seatsElecN == 0 & partyVotePC <5 ~ NA_real_,
== "IND" ~ NA_real_,
PartyAb .default = quot))
# Identify the party with the highest quotient, and increment their entitled seat count
which.max(mmp$quot),]$seatsEntitledN = mmp[which.max(mmp$quot),]$seatsEntitledN + 1
mmp[ }
Now we’ll tidy this up a bit.
# Calculate overhang
= mmp %>%
mmp mutate(overhang = ifelse((seatsElecN > seatsEntitledN) & !is.na(quot), seatsElecN - seatsEntitledN, 0))
# Determine the makeup of parliament
= mmp %>%
mmp mutate(seatsListN = ifelse(seatsEntitledN > seatsElecN, seatsEntitledN - seatsElecN, 0),
seatsTotalN = seatsElecN + seatsListN,
# Calculate percentages
seatsTotalPC = (seatsTotalN / sum(seatsTotalN)) %>%
multiply_by(100) %>%
round(digits = 2),
seatsEntitledPC = (seatsEntitledN/sum(seatsEntitledN)) %>%
multiply_by(100) %>%
round(digits = 2)) %>%
select(-quot) %>%
left_join(votes %>%
group_by(PartyAb, PartyNm) %>%
summarise()) %>%
relocate(seatsEntitledPC,
.after = "seatsEntitledN") %>%
relocate(PartyNm,
.after = "PartyAb")
There’s a couple of observations that leap out to me here:
- We have a moderate number of overhang seats9
- 3 for the LNP
- 1 for the nationals
- 3 independents
- The seat allocations nicely match the proportion of 1st preference votes
This really shouldn’t be that surprising (it is, after all, the system working as designed) but it is nice to see that it validates some of the other assumptions made about translating preferences to party vote.
9 This ratio appears proportional to me. Given that the independents are guaranteed under the assumptions (and are therefore “free”), we end up with ~1 overhang seat per 55 total seats, which is similar to the current NZ parliament.
Mapping the Results
The next step is to plot the result onto an interactive map of the electorates, with insets for the high density regions. Geophysical analysis is often pretty verbose, but we’ll step through it in a logical sequence. We’ll illustrate this with first past the post data.
Firstly, we join our shapefile ced
to our FPTP outcomes data. This is easy - we’ve already checked and ensured the electorate names share a common variable in both dataframes during data preparation. We then reduce the complexity of the graph10 and produce our map with standard ggplot
functions.
10 st_simplify
is a handy function that combines adjacent vectors, which decreases the time it takes to produce the plot (relevant when iterating through cosmetic choices) as well as the size of the overall file. We could do this in the data cleaning stage, but I want to keep a high level of detail for the insets, so we’ll do it each time instead.
= ced %>%
map.data.fptp left_join(fptp)
= map.data.fptp %>%
map.fptp st_simplify(dTolerance = 1000) %>% # We do this here because we want the insets to be high detail
ggplot() +
geom_sf_interactive(aes(fill = PartyAb,
tooltip = tooltip,
data_id = DivisionID),
lwd = 0.05) +
# Themeing
scale_fill_manual(values = colourScale) +
labs(fill = element_blank(),
x = NULL,
y = NULL) +
theme_void() +
theme(plot.margin = unit(c(0,0,0,0), "cm")) # ("left", "right", "bottom", "top")
girafe(ggobj = map.fptp)
Not a bad start, but as expected the districts in the urban regions are too small to be interpretable. We can improve the readability of this by creating some insets for the major cities11.
11 We define fn.mini_map
to automate this - basically it draws a circle of a given radius around a point, captures all the electorates that are fully or partially within that circle, and then draws a new map of that subset.
# Make a mini-map for Melbourne
= fn.mini_map(st = map.data.fptp,
map.fptp.mel lat = loc[loc$city == "mel",]$lat,
long = loc[loc$city == "mel",]$long,
rad = loc[loc$city == "mel",]$rad)
# ... Repeat for the other cities
We then combine these insets with the full map to produce a hybrid map. There’s still room for improvement, but this is good enough for now I think.
We’ll finish up with part 3, doing some comparative analysis of each system.