This vignette explores the implementation of different seat apportionment scenarios. There are generally two ways to assign seats of a parliament: Either you distribute seats within each district independently or you distribute seats according to the vote share for the whole election region (e.g. on a national level). We will focus on the difference in seat shares compared to vote shares these two approaches can produce.
We’ll mainly use the data set for the 2019 Finnish parliamentary
elections. The finland2019
data set contains two
data.frames: One with the number of votes for each party, the other
data.frame tells us how many seats (election_mandates
) each
district has.
library(proporz)
str(finland2019)
#> List of 2
#> $ votes_df :'data.frame': 229 obs. of 3 variables:
#> ..$ party_name : chr [1:229] "KOK" "SDP" "VIHR" "PS" ...
#> ..$ district_name: chr [1:229] "UUS" "UUS" "HEL" "UUS" ...
#> ..$ votes : int [1:229] 114243 97107 90662 86691 84141 78486 73626 66109 59722 55244 ...
#> $ district_seats_df:'data.frame': 12 obs. of 2 variables:
#> ..$ district_name: chr [1:12] "HEL" "HÄM" "KAA" "KES" ...
#> ..$ seats : int [1:12] 22 14 17 10 7 18 19 8 15 36 ...
To make comparisons easier, we’ll use matrices instead of
data.frames. We create a votes_matrix
from the given
data.frame.
votes_matrix = pivot_to_matrix(finland2019$votes_df)
dim(votes_matrix)
#> [1] 44 12
# Let's look at all parties with at least 10k votes
knitr::kable(votes_matrix[rowSums(votes_matrix) > 10000,])
HEL | HÄM | KAA | KES | LAP | OUL | PIR | SAT | SKA | UUS | VAA | VAR | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
KD | 7253 | 11733 | 10875 | 8774 | 1077 | 5728 | 17180 | 3361 | 17389 | 14912 | 16741 | 5121 |
KESK | 11015 | 20892 | 40715 | 30597 | 29145 | 78486 | 26536 | 20878 | 50459 | 35348 | 50053 | 29796 |
KOK | 84141 | 39511 | 44224 | 19707 | 11243 | 28382 | 55244 | 17666 | 27699 | 114243 | 29530 | 52367 |
NYT | 13529 | 0 | 0 | 0 | 0 | 1593 | 0 | 0 | 0 | 0 | 0 | 0 |
Nyt | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 24778 | 0 | 0 |
PIR | 5840 | 996 | 687 | 1239 | 280 | 1244 | 2018 | 253 | 686 | 4083 | 462 | 1244 |
PS | 47276 | 43199 | 46196 | 27851 | 17160 | 52771 | 51819 | 30090 | 39996 | 86691 | 42843 | 52913 |
RKP | 20348 | 500 | 0 | 0 | 112 | 432 | 327 | 178 | 0 | 49625 | 52880 | 15238 |
SDP | 52393 | 49359 | 59722 | 29201 | 13479 | 26588 | 66109 | 31475 | 38274 | 97107 | 33608 | 49156 |
SIN | 2056 | 821 | 3212 | 705 | 483 | 1801 | 3342 | 0 | 4128 | 9498 | 2470 | 1427 |
STL | 1285 | 1028 | 829 | 389 | 667 | 847 | 1371 | 199 | 769 | 2754 | 777 | 451 |
VAS | 42899 | 15172 | 10371 | 12681 | 14128 | 33820 | 24341 | 12435 | 15908 | 26257 | 8315 | 35481 |
VIHR | 90662 | 17354 | 23032 | 17639 | 9706 | 20376 | 37152 | 7461 | 21362 | 73626 | 10515 | 25309 |
We also create a named vector that defines how many seats each
district has (district_seats
). Note that the order of
district names in district_seats
and columns in
votes_matrix
differ. However, this does not affect the
analysis as we access districts by name (as does
biproporz()
by the way).
district_seats = finland2019$district_seats_df$seats
names(district_seats) <- finland2019$district_seats_df$district_name
district_seats
#> HEL HÄM KAA KES LAP OUL PIR SAT SKA UUS VAA VAR
#> 22 14 17 10 7 18 19 8 15 36 16 17
In Finland and many other jurisdictions, seats are assigned for each
district independently. There is no biproportional apportionment among
all districts. The following function, apply_proporz
,
calculates the seat distribution for each district and returns the
number of seats per party and district as a matrix. The user can specify
the apportionment method and a quorum threshold.
apply_proporz = function(votes_matrix, district_seats, method, quorum = 0) {
seats_matrix = votes_matrix
seats_matrix[] <- NA
# calculate proportional apportionment for each district (matrix column)
for(district in names(district_seats)) {
seats_matrix[,district] <- proporz(votes_matrix[,district],
district_seats[district],
quorum = quorum,
method = method)
}
return(seats_matrix)
}
The Finnish election system uses the D’Hondt method. We’ll calculate the seat distribution as a baseline to compare it with other methods.
bydistrict_v0 = apply_proporz(votes_matrix, district_seats, "d'hondt")
bydistrict_v0[rowSums(bydistrict_v0) > 0,]
#> district_name
#> party_name HEL HÄM KAA KES LAP OUL PIR SAT SKA UUS VAA VAR
#> KD 0 1 0 0 0 0 1 0 1 1 1 0
#> KESK 0 1 3 3 3 6 2 1 4 2 4 2
#> KOK 6 3 3 1 1 2 4 1 2 9 2 4
#> Nyt 0 0 0 0 0 0 0 0 0 1 0 0
#> PS 3 3 4 2 1 4 4 2 3 6 3 4
#> RKP 1 0 0 0 0 0 0 0 0 3 4 1
#> SDP 3 4 5 2 1 2 5 3 3 7 2 3
#> VAS 3 1 0 1 1 3 1 1 1 2 0 2
#> VIHR 6 1 2 1 0 1 2 0 1 5 0 1
We’ll now look at alternative apportionment methods. The Sainte-Laguë method (standard rounding) for example is impartial to party size. On the other hand, the Huntington–Hill method favors small parties. Generally, Huntington-Hill is used with a quorum, otherwise all parties with more than zero votes get at least one seat. In this example, the quorum is 3% of votes in a district.
bydistrict_v1 = apply_proporz(votes_matrix, district_seats,
method = "sainte-lague")
bydistrict_v2 = apply_proporz(votes_matrix, district_seats,
method = "huntington-hill",
quorum = 0.03)
Let’s compare the seat distributions for these three methods. We get
the number of party seats on the national level with
rowSums
.
df_bydistrict = data.frame(
D.Hondt = rowSums(bydistrict_v0),
Sainte.Lague = rowSums(bydistrict_v1),
Huntington.Hill = rowSums(bydistrict_v2)
)
# sort table by D'Hondt seats
df_bydistrict <- df_bydistrict[order(df_bydistrict[[1]], decreasing = TRUE),]
# print parties with at least one seat
knitr::kable(df_bydistrict[rowSums(df_bydistrict) > 0,])
D.Hondt | Sainte.Lague | Huntington.Hill | |
---|---|---|---|
SDP | 40 | 36 | 36 |
PS | 39 | 37 | 37 |
KOK | 38 | 35 | 35 |
KESK | 31 | 29 | 29 |
VIHR | 20 | 25 | 27 |
VAS | 16 | 18 | 18 |
RKP | 9 | 8 | 8 |
KD | 5 | 7 | 6 |
Nyt | 1 | 2 | 2 |
NYT | 0 | 1 | 1 |
SIN | 0 | 1 | 0 |
The actual political analysis is better left for people familiar with the party system. However, it is fair to say that the election system within districts has a significant impact on the number of seats in parliament. For example, the party with the most seats changes with Sainte-Laguë or Huntington-Hill.
We’ll now look at biproportional apportionment and whether it better
matches the seat share with the national vote share. Keep in mind that
simply using existing data sets with biproporz
is not
really suitable from a modeling perspective. Vote distributions are
likely to be different if, for example, people know their vote also
counts on a national level or if smaller parties choose to run in more
districts.
With that being said, let’s assume our data set is properly modeled
already. We need to consider that voters in Finland can only vote for
one person in each district. They can’t distribute as many
votes as there are seats in their district among candidates/parties.
Thus we need to use use_list_votes=FALSE
as a parameter in
biproporz
.
seats_biproportional = biproporz(votes_matrix,
district_seats,
use_list_votes = FALSE)
# show only parties with seats
seats_biproportional[rowSums(seats_biproportional) > 0,]
HEL | HÄM | KAA | KES | LAP | OUL | PIR | SAT | SKA | UUS | VAA | VAR | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
KD | 0 | 1 | 1 | 1 | 0 | 1 | 1 | 0 | 1 | 1 | 1 | 0 |
KESK | 1 | 1 | 3 | 2 | 2 | 6 | 2 | 1 | 3 | 2 | 3 | 2 |
KOK | 5 | 3 | 3 | 1 | 1 | 2 | 4 | 1 | 2 | 7 | 2 | 4 |
KP | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
NYT | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Nyt | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 0 | 0 |
PIR | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
PS | 3 | 3 | 3 | 2 | 1 | 4 | 3 | 2 | 3 | 5 | 3 | 3 |
RKP | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 3 | 1 |
SDP | 3 | 4 | 4 | 2 | 1 | 2 | 4 | 2 | 3 | 6 | 2 | 3 |
SIN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 |
STL | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
VAS | 2 | 1 | 1 | 1 | 1 | 2 | 2 | 1 | 1 | 2 | 1 | 2 |
VIHR | 5 | 1 | 2 | 1 | 1 | 1 | 2 | 1 | 1 | 5 | 1 | 2 |
Let’s now look at the difference between vote and seat share.
vote_shares = rowSums(votes_matrix)/sum(votes_matrix)
seat_shares = rowSums(seats_biproportional)/sum(seats_biproportional)
range(vote_shares - seat_shares)
#> [1] -0.005144852 0.002429257
As we can see, the difference between vote and seat share ranges from -0.5 to +0.2 percentage point. Biproportional apportionment matches the national vote share better than apportionment by district. This is expected however, since biproportional apportionment actually considers the national vote share. Discussing the pros and cons of a regional representation compared to a priority on national vote shares is not within the scope of this vignette. The following chunk shows the seat changes.
seat_changes = seats_biproportional-bydistrict_v0
knitr::kable(seat_changes[rowSums(abs(seat_changes)) > 0,colSums(abs(seat_changes))>0])
HEL | KAA | KES | LAP | OUL | PIR | SAT | SKA | UUS | VAA | VAR | |
---|---|---|---|---|---|---|---|---|---|---|---|
KD | 0 | 1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
KESK | 1 | 0 | -1 | -1 | 0 | 0 | 0 | -1 | 0 | -1 | 0 |
KOK | -1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -2 | 0 | 0 |
KP | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
NYT | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Nyt | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
PIR | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
PS | 0 | -1 | 0 | 0 | 0 | -1 | 0 | 0 | -1 | 0 | -1 |
RKP | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | -1 | 0 |
SDP | 0 | -1 | 0 | 0 | 0 | -1 | -1 | 0 | -1 | 0 | 0 |
SIN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 |
STL | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
VAS | -1 | 1 | 0 | 0 | -1 | 1 | 0 | 0 | 0 | 1 | 0 |
VIHR | -1 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | 1 |
Normally the number of seats per district is defined before an
election, based on census data. However, you could also assign
the number of seats per district, based on the actual votes cast in
every district. This way, each districts seat share is proportional to
its vote share. To do this, we use the total number of seats for the
district_seats
parameter.
full_biproportional = biproporz(votes_matrix,
district_seats = sum(district_seats),
use_list_votes = FALSE)
# party seat distribution has not changed
rowSums(full_biproportional) - rowSums(seats_biproportional)
#> Asyl E117 E118 E154 E168 E169 E185 E191 E192 E287 E491
#> 0 0 0 0 0 0 0 0 0 0 0
#> E492 E493 EOP FP IP KD KESK KOK KP KTP LIBE
#> 0 0 0 0 0 0 0 0 0 0 0
#> LII LIIK LN LNLY LNY LNYL NYT Nyt PEL PIR PS
#> 0 0 0 0 0 0 0 0 0 0 0
#> REF RKP RLI Reform SDP SIN SKE SKP STL VAS VIHR
#> 0 0 0 0 0 0 0 0 0 0 0
# district seat distribution is different
colSums(full_biproportional) - colSums(seats_biproportional)
#> HEL HÄM KAA KES LAP OUL PIR SAT SKA UUS VAA VAR
#> 3 -1 -1 0 0 -1 0 0 -1 0 0 1
As we can see, the number of party seats does not change. However, districts where more people voted get a higher share of the seats. 2 districts gain seats, 4 districts lose a seat.
As a last example, we’ll look at the changes in seat distribution biproportional apportionment had in the 2020 parliament election in the Swiss canton of Uri. In Uri, 2020 was the first year the cantonal parliament was elected with biproportional apportionment for some municipalities. Previously, these four municipalities used the Hagenbach-Bischoff method. We’ll ignore all other municipalities which use a majoritarian electoral system.
seats_old_system = apply_proporz(uri2020$votes_matrix, uri2020$seats_vector, "hagenbach-bischoff")
seats_new_system = biproporz(uri2020$votes_matrix, uri2020$seats_vector)
seats_new_system-seats_old_system
#> Altdorf Bürglen Erstfeld Schattdorf
#> CVP 1 0 0 0
#> SPGB -1 0 0 0
#> FDP 0 0 0 0
#> SVP 0 0 0 0
Compared to the previous election system there was a change of one seat (out of 37).