One-Forty-One 2024: Lithuanian Parliament Results

Author

Richard Martin-Nielsen

Published

October 28, 2024

Introduction

Early this morning I completed and circulated my prediction for the outcome of the second round of Lithuania’s 2024 Parliamentary Elections. The polls closed this evening and we have some preliminary results.

My model didn’t do too well.

This is going to be a fairly rough and quick comparison because I didn’t get much sleep last night. It’ll take me more time and more detailed data to start to figure out what went wrong.

Code
library(tidyr)
library(knitr)
interim_outcomes <- tibble::tribble(
  ~Party, ~`Predicted Outcome`, ~`Interim Results`, ~Difference, 
"LSDP", 41, 52, 11, 
  "TS-LKD", 40, 28, -12, 
  "PPNA", 21, 20, -1, 
  "DSVL", 11, 14, 3, 
  "LVŽS", 11, 8, -3, 
  "LS", 9, 12, 3, 
  "Independent", 4, 2, -2, 
  "LLRA", 3, 3, 0, 
  "LP", 1, 0, -1, 
  "LT", 0, 0, 0, 
  "PLT", 0, 1, 1, 
  "NS", 0, 1, 1, 

)
kable(interim_outcomes)
Party Predicted Outcome Interim Results Difference
LSDP 41 52 11
TS-LKD 40 28 -12
PPNA 21 20 -1
DSVL 11 14 3
LVŽS 11 8 -3
LS 9 12 3
Independent 4 2 -2
LLRA 3 3 0
LP 1 0 -1
LT 0 0 0
PLT 0 1 1
NS 0 1 1

My calculations over-estimated TS-LKD winning power (I’ll be curious to see whether that’s where they Led or were Challenging) and underestimated LSDP power (in 2020 I think the LSDP vote may have split partially to the LSDDP).

It was overoptimistic for LVŽS and underestimated LS. (It might be worth looking at whether LP voters moved to LS when there was no LP in round 2.)

It got LLRA and LT right, and was within 1 seat for PPNA, LP and PLT.

If there is a “vote against the governing parties” bias in Lithuanian politics (and I’m starting to think there may be) then I may need to factor that into my simulation.

But let’s look at the details a bit.

In the single-member constituencies, if a candidate wins over 50% in the first round (40% beginning in 2024) they are elected. If no candidate meets this threshold, the top two candidates participate in a run-off election two weeks later.

Code
second_round_results<- aggregate_round_II %>% 
  mutate(party = replace_na(party, "Independent")) %>%
  dplyr::group_by(year, constituency, given_name, family_name, party) 

second_round_subtotals <- second_round_results%>%
  dplyr::group_by(year,constituency) %>%
  dplyr::mutate(total = sum(votes), perc = votes / total)
winner_round_II <-second_round_subtotals %>%
  dplyr::group_by(year,constituency) %>%
  arrange(desc(votes)) %>%
  dplyr::mutate(rank = row_number()) %>%
  pivot_wider(
    id_cols = c(year,constituency),
    names_from = rank,
    values_from = c(Acronym, perc, votes, total, given_name, family_name)
  ) %>%
  dplyr::mutate(separation = perc_1 - perc_2)
Code
##
# Build a wide multi-year table of results of past elections
#
linear_wide_multiyear <- NA

for (thisYear in c(2012, 2016,2020)) {

    first_round_results <- aggregate_round_I %>%
        group_by(year, constituency) %>%
      filter(max(perc)<0.5,year==thisYear) %>%
      arrange(desc(votes)) %>%
      dplyr::mutate(rank = row_number()) #%>%
  
  # Add a new row which contains a fake candidate for each constituency
  # who collects all the votes given to candidates who do not progress
  # to the second round
  ##
  first_round_subtotals <- first_round_results %>%
    ungroup() %>%
    split(.$constituency) %>%
    map(
      ~ add_row(
        .,
        year = thisYear,
        given_name = "An",
        family_name = "Other",
        constituency = unique(.$constituency),
        constituency_number = unique(.$constituency_number),
        party = "Other",
        #eligible_voters = NA,
        votes = sum(.$votes, na.rm = TRUE),
        total = NA,
        perc = NA
      )
    ) %>%
    bind_rows() %>%
    group_by(constituency, constituency_number) %>%
    dplyr::mutate(carry = if_else(rank < 3, votes, 0)) %>%
    dplyr::mutate(carry = if_else(is.na(rank), votes - sum(carry, na.rm =
                                                             TRUE), carry)) %>%
    dplyr::mutate(votes = if_else(is.na(rank), 0, votes))
  
  linear_combined <-
    left_join(
      first_round_subtotals,
  aggregate_round_II,
      by = c("year","given_name", "family_name", "constituency", "constituency_number", "party", "Acronym"),
      suffix = c("_I", "_II")
    )
  
  linear_wide <- linear_combined %>%
    select(-given_name,-family_name) %>%
    filter(carry > 0) %>%
    pivot_wider(
      id_cols = c(year,constituency),
      names_from = rank,
      values_from = c(Acronym, carry, votes_II)
    ) %>%
    #dplyr::select(-party_NA, -votes_II_NA) %>%
    dplyr::rename(final_1 = votes_II_1, final_2 = votes_II_2) %>%
    dplyr::mutate(matchup = paste(Acronym_1, Acronym_2)) %>%
    #dplyr::mutate(Acronym_1 = factor(Acronym_1, levels = ordered_party_list_all_years, ordered = TRUE),
    #              Acronym_2 = factor(Acronym_2, levels = ordered_party_list_all_years, ordered = TRUE)) %>%
        dplyr::mutate(flip = if_else((final_1 < final_2), TRUE, FALSE))
  if ( length(linear_wide_multiyear)== 1) {
    # This used to test against is.na but that stopped working sometime
    # between 2020 and 2024
     linear_wide_multiyear <- linear_wide
   } else {
    linear_wide_multiyear <- 
      bind_rows(linear_wide_multiyear, linear_wide)  
   }
}

Part A - Vote share and Vote lead

Code
# Set the thresholds for the heuristic we'll use - it's here so I can
# refer to it in the body of the text later.
VoteShareThreshold <- 0.4
VoteGapThreshold <- 0.16

# But let's be quick and work from VRK summaries:
First_round_results_2024_10_14_0849_cet_Rezultatai_vrk_lt <- read_excel("First round results 2024-10-14 0849 cet Rezultatai - vrk.lt.xlsx", 
    skip = 8)

Multimember_results_2024_10_14_1830_cet_Rezultatai_vrk_lt <- read_excel("Multimember results 2024-10-14 1830 cet Rezultatai - vrk.lt.xlsx", 
                                                                        sheet="data_lt",
    skip = 11) %>%
  rename(Candidate="Kandidatas", "Electoral Constituency"="Apygarda", "Nominated by"="Partijos, koalicijos pavadinimas")
## Work out MMP winners

multimember_party_results <-
  Multimember_results_2024_10_14_1830_cet_Rezultatai_vrk_lt %>%
    filter(`Electoral Constituency`=="Daugiamandatė") %>%
    select(-`Electoral Constituency`) %>%
    left_join(party_names, by=c("Nominated by"="Name"))
MPs_elected_first_round <- Multimember_results_2024_10_14_1830_cet_Rezultatai_vrk_lt %>%
  filter(`Electoral Constituency`!="Daugiamandatė") %>%
    select(-`Electoral Constituency`) %>%
    left_join(party_names, by=c("Nominated by"="Name"))
  
## work out first round leaders

# Set the thresholds for the heuristic we'll use - it's here so I can
# refer to it in the body of the text later.
VoteShareThreshold <- 0.4
VoteGapThreshold <- 0.16
VoteGapThreshold2024 <- 0.17

FirstRoundFractions_2024 <- linear_wide_multiyear %>%
  filter(year == 2024) %>%
  mutate(`Vote Share` = carry_1/(carry_1+carry_2+carry_NA),
         `Vote Gap` = (carry_1-carry_2)/(carry_1+carry_2+carry_NA)) %>%
  rename(Leader=Acronym_1, Challenger=Acronym_2)

WithinThresholds_2024 <- FirstRoundFractions_2024 %>%
  filter(`Vote Share`<VoteShareThreshold, `Vote Gap`<VoteGapThreshold2024) 

LikelyElected_2024 <- FirstRoundFractions_2024 %>%
  filter(`Vote Share`>=VoteShareThreshold || `Vote Gap`>=VoteGapThreshold2024) 
expected_winners_part_A <- LikelyElected_2024

LeaderParties_2024 <- FirstRoundFractions_2024 %>% 
  group_by(Leader) %>%
  tally()

#
# Add in the first round results for 2024
#
  for (thisYear in c(2024)) {
      first_round_results <- aggregate_round_I %>%
        group_by(year, constituency) %>%
      filter(max(perc)<0.4,year==thisYear) %>%
      arrange(desc(votes)) %>%
      dplyr::mutate(rank = row_number()) #%>%
  
  # Add a new row which contains a fake candidate for each constituency
  # who collects all the votes given to candidates who do not progress
  # to the second round
  ##
  first_round_subtotals <- first_round_results %>%
    ungroup() %>%
    split(.$constituency) %>%
    map(
      ~ add_row(
        .,
        year = thisYear,
        given_name = "An",
        family_name = "Other",
        constituency = unique(.$constituency),
        constituency_number = unique(.$constituency_number),
        party = "Other",
        #eligible_voters = NA,
        votes = sum(.$votes, na.rm = TRUE),
        total = NA,
        perc = NA
      )
    ) %>%
    bind_rows() %>%
    group_by(constituency) %>%
    dplyr::mutate(carry = if_else(rank < 3, votes, 0)) %>%
    dplyr::mutate(carry = if_else(is.na(rank), votes - sum(carry, na.rm =
                                                             TRUE), carry)) %>%
    dplyr::mutate(votes = if_else(is.na(rank), 0, votes))
  
  linear_combined <- first_round_subtotals %>%
    rename(votes_I=votes,total_I=total,perc_I=perc) %>%
    mutate(votes_II=NA,total_II=NA, perc_I=NA)

  linear_wide <- linear_combined %>%
    select(-given_name,-family_name) %>%
    filter(carry > 0) %>%
    pivot_wider(
      id_cols = c(year,constituency,constituency_number),
      names_from = rank,
      values_from = c(Acronym, carry, votes_II)
    ) %>%
    #dplyr::select(-party_NA, -votes_II_NA) %>%
    dplyr::rename(final_1 = votes_II_1, final_2 = votes_II_2) %>%
    dplyr::mutate(matchup = paste(Acronym_1, Acronym_2)) %>%
    dplyr::mutate(Acronym_1 = factor(Acronym_1, levels = ordered_party_list_all_years, ordered = TRUE),
                  Acronym_2 = factor(Acronym_2, levels = ordered_party_list_all_years, ordered = TRUE)) %>%
        dplyr::mutate(flip = if_else((final_1 < final_2), TRUE, FALSE))
  # This used to test against is.na but that didn't work
  if (length(linear_wide_multiyear)==1) {
    linear_wide_multiyear <- linear_wide
  } else {
    linear_wide_multiyear <- 
      bind_rows(linear_wide_multiyear, linear_wide)  
  }
  }

Part_A_Winners_2024 <- linear_wide_multiyear %>% 
  filter(year == 2024) %>%
  mutate(`Vote Share` = carry_1/(carry_1+carry_2+carry_NA),
         `Vote Gap` = (carry_1-carry_2)/(carry_1+carry_2+carry_NA)) %>%
  select(constituency, constituency_number, Acronym_1, Acronym_2, matchup, `Vote Share`, `Vote Gap` ) %>%
  rename(Party=Acronym_1, Challenger=Acronym_2) %>%
  mutate(constituency_number = as.numeric(constituency_number)) %>%
  filter(`Vote Gap` >= VoteGapThreshold2024, `Vote Share`< VoteShareThreshold) %>%
  unique() %>%
  arrange(constituency_number) %>%
  relocate(constituency_number) %>%
  mutate(`Vote Share` = `Vote Share`*100,
         `Vote Gap` = `Vote Gap`*100) %>%
  rename("Constituency Number" = constituency_number,
         Constituency = constituency,
         "Match-up" = matchup)


Part_A_Parties_2024 <- Part_A_Winners_2024 %>%
  group_by(Party) %>%
  tally()

Candidates who led with more than 40% of the vote in the first round all won in the second round in 2012 and 2016, and then the law seems to have changed. But many candidates with less than 40% of the vote won in both rounds.

When I looked at the 2024 data, I chose to move the threshold up slightly, to 17%

Code
party_colours_names <- setNames(party_colours$Colour, party_colours$Acronym)
VoteGapThreshold2024 <- VoteGapThreshold + 0.01

linear_wide_multiyear %>%
  filter(year == 2024) %>%
  mutate(`Vote Share` = carry_1/(carry_1+carry_2+carry_NA),
         `Vote Gap` = (carry_1-carry_2)/(carry_1+carry_2+carry_NA)) %>%
  rename(Party=Acronym_1) %>%
  mutate(LikelyWin = if_else((`Vote Gap`) >= VoteGapThreshold2024, 1, 0.75)) %>%
  ggplot() +
  theme_minimal() +
  geom_point(aes(x=`Vote Share`, y=`Vote Gap`,
                 color=Party, alpha=LikelyWin)) +
  scale_colour_manual(values = party_colours_names) +
  guides(alpha="none") +
  xlim(0.2,0.4) +
  ylim(0.1, 0.25) +
  scale_x_continuous(labels=label_percent()) +
  scale_y_continuous(labels=label_percent()) +
  geom_vline(xintercept=VoteShareThreshold) +
  geom_hline(yintercept=VoteGapThreshold2024) 

This assigned three seats to TS-LKD and two seats to the LSDP.

Code
kable(Part_A_Winners_2024, digits=2)
Constituency Number Constituency Party Challenger Match-up Vote Share Vote Gap
6 Šeškinės–Šnipiškių TS-LKD LSDP TS-LKD LSDP 31.19 18.42
18 Panemunės TS-LKD LSDP TS-LKD LSDP 31.13 17.65
20 Centro–Žaliakalnio TS-LKD LS TS-LKD LS 31.06 19.32
30 Alytaus LSDP TS-LKD LSDP TS-LKD 32.45 17.46
42 Raseinių–Josvainių LSDP LVŽS LSDP LVŽS 39.74 23.32

Interestingly, this approach assigned Panemunė to the TS-LKD; current VRK interim results (23:00 Vilnius time, 27 October) show the LSDP candidate (Audrius RADVILAVIČIUS) winning the second round with 52% of the vote. Note that the LSDP candidate who had a lower vote gap – but higher vote share – did win their seat, in Alytus.

Part B - Estimating outcomes based on past success rates

In general, candidates who placed first in the first round win the second round, but the success rates vary from year to year. I refer to the candidate who won the most votes in the first round as the Leader, and the second-placed candidate as the Challenger.

  • 85% of first round winners won the second round in 2012
  • 69% of first round winners won the second round in 2016
  • 84% of first round winners won the second round in 2020
  • This masks wide variation among parties and between years
    • TS-LKD retained their lead in 87.5% times in 2012 but only 40% in 2016

We can look at the success rates of the different parties from year to year, which emphasises how unusual 2016 was compared with 2012 and 2020 (and particularly how bad it was for TS-LKD, LSDP and independent candidates). This does mask how many candidates are being considered at each point.

Code
bind_rows(successRates, summaryRows) %>%
  mutate(`Success Rate` = `Success Rate (%)`/100) %>%
  ggplot() +
  geom_line(aes(x=year, y=`Success Rate`, colour=Party)) +
  #geom_jitter(aes(x=year, y=`Success Rate (%)`, colour=Party), height=0.0, width=0.1) +
  geom_point(aes(x=year, y=`Success Rate`, colour=Party)) +
  theme_minimal() +
  scale_colour_manual(values = party_colours_names) +
  
  #xlim(2011,2021) +
  scale_x_continuous(breaks=c(2012,2016,2020), limits=c(2011,2021)) +
  scale_y_continuous(limits=c(0,1), labels = label_percent()) +
  geom_hline(yintercept=2/3)

Code
successRatesPartyToParty <- linear_wide_multiyear %>% 
  filter(year<2024) %>%
  group_by(year,Acronym_1, Acronym_2, flip) %>% tally() %>%
  group_by(year) %>% arrange(year,Acronym_1, Acronym_2) %>%
  pivot_wider(id_cols=c(year, Acronym_1, Acronym_2), names_from=flip,values_from=n) %>%
  rename(Leader=Acronym_1,Challenger=Acronym_2, Won='FALSE',Lost='TRUE') %>%
  select(-'NA') %>%
  replace_na(list(Party="None", Won=0, Lost=0)) %>%
  mutate(Total = Won + Lost, "Success Rate (%)"=Won/Total*100)

Directly elected and leading

I made a graph which showed both those confirmed as elected and those leading in the first round in single-member constituencies, and added in the 5 seats where the vote gap is above 17%, counting them as confirmed – even Panemunės.

This is somewhat useful and suggests that these seats are uncertain, but doesn’t reflect who the challenger is for each constituency.

Code
parl_data <- bind_rows(MPs_elected_first_round, multimember_party_results) %>%
  rename(party_long=`Nominated by`, party_short=Acronym) %>%
  group_by(party_long, party_short) %>%
  tally(name="seats") 

first_round_parl_data <- parliament_data(
  election_data = parl_data,
  type="semicircle", party_seats = parl_data$seats,
  parl_rows = 4)
parl_data_leaders <-
  FirstRoundFractions_2024 %>%
  mutate("elected"=if_else(`Vote Gap`>VoteGapThreshold2024, TRUE, FALSE)) %>%
  select(party_short=Leader, elected) %>%
  mutate(party_long=party_short) %>%
    group_by(party_long, party_short, elected) %>%
  tally(name="seats")
  
parl_data_with_leaders <- bind_rows(parl_data %>%
    mutate("elected"=TRUE),
  parl_data_leaders) %>%
  mutate(party_short = if_else(is.na(party_short), "Independent", party_short)) %>%
  mutate(party_long = party_short) %>%
  mutate(party_short = factor(party_short,
                                 levels=ordered_party_list_all_years)) %>%
  arrange(party_short,elected)

first_round_parl_data_w_leaders <- parliament_data(
  election_data = parl_data_with_leaders,
  type="thirdcircle", party_seats = parl_data_with_leaders$seats,
  parl_rows = 6)

# ggplot(first_round_parl_data_w_leaders, aes(x=x, y=y, colour=party_short)) +
#   geom_parliament_seats(size=4) +
#   theme_ggparliament() +
#   scale_colour_manual(values = party_colours_names) +
#   guides(colour = guide_legend(title=NULL)) +
#   geom_emphasize_parliamentarians(elected, size=4) +
#   labs( title="MPs elected and leading to the Seimas in the first round 2024")

Choosing success rates for simulation

When we looked at success rates year to year earlier, 2016 looked very odd. A key question for 2024 is whether we think 2016 was an outlier, or whether it reflects a broader range of how Lithuanian parties perform in elections.

In the end, I treated 2016 as an outlier, and base calculations for party-by-party success rates on the data for 2012 and 2020. Looking back less than 24 hours later, I wonder if this was a mistake.

Code
adjustedSuccessRates <- linear_wide_multiyear %>%
  filter(year %in% c(2012,2020))%>%
  mutate(VoteGap=(carry_1-carry_2)/(carry_1+carry_2+carry_NA),perc=(carry_1)/(carry_1+carry_2+carry_NA)) %>%
  filter((VoteGap <= VoteGapThreshold) & (perc <= VoteShareThreshold )) %>%
  mutate(-VoteGap,-VoteGapThreshold) %>%
  group_by(Acronym_1,flip) %>% tally() %>%
  arrange(Acronym_1) %>%
  pivot_wider(id_cols=c(Acronym_1), names_from=flip,values_from=n) %>%
  rename(Party=Acronym_1,Won='FALSE',Lost='TRUE') %>%
  select(-'NA') %>%
  replace_na(list(Party="None", Won=0, Lost=0)) %>%
  mutate(Total = Won + Lost, "Success Rate (%)"=Won/Total*100)
kable(adjustedSuccessRates, digits = 2)
Party Won Lost Total Success Rate (%)
DP 7 4 11 63.64
Independent 3 1 4 75.00
LLRA 1 1 2 50.00
LP 2 0 2 100.00
LRLS 7 2 9 77.78
LSDDP 2 0 2 100.00
LSDP 17 1 18 94.44
LT 1 0 1 100.00
LVŽS 11 0 11 100.00
LŽP 1 0 1 100.00
TS-LKD 31 11 42 73.81
TT 2 1 3 66.67
Code
# Maximum
MaxLeaderSuccess <- 80
# Typical (for where we have no previous data)

# This is what the figure was in the past, it is now being bumped up to 2/3
#AverageLeaderSuccess <- 60
AverageLeaderSuccess <- 2/3*100

We’ll apply a maximum of 80% and assign a typical chance of success of 66.6666667% where we don’t have earlier data. For the Liberalų sąjūdis, I treated them as a continuation of the LRLS.

Code
newParties <- tribble(
  ~Party, ~Won, ~Lost, ~Total, ~`Success Rate (%)`,
  # New parties for 2024
  "PPNA", 0, 0, 0, NA,
  "DSVL", 0, 0, 0, NA,
  "LS", 7, 2, 9, 7/9,
  # Old parties from 2020 calculations
  #"LP", 0, 0, 0, NA,
  #"LT", 0, 0, 0, NA,
  #"LSDDP", 0, 0, 0, NA,
  #"LŽP", 0, 0, 0, NA
)

round_II_probability <- bind_rows(adjustedSuccessRates,newParties) %>%
  mutate("Probability"= if_else(Total==0, AverageLeaderSuccess, if_else(`Success Rate (%)`> MaxLeaderSuccess, MaxLeaderSuccess, `Success Rate (%)`)))

Round_II_Parties_2024 <- round_II_probability %>% select(Party)

kable(round_II_probability,digits=2)
Party Won Lost Total Success Rate (%) Probability
DP 7 4 11 63.64 63.64
Independent 3 1 4 75.00 75.00
LLRA 1 1 2 50.00 50.00
LP 2 0 2 100.00 80.00
LRLS 7 2 9 77.78 77.78
LSDDP 2 0 2 100.00 80.00
LSDP 17 1 18 94.44 80.00
LT 1 0 1 100.00 80.00
LVŽS 11 0 11 100.00 80.00
LŽP 1 0 1 100.00 80.00
TS-LKD 31 11 42 73.81 73.81
TT 2 1 3 66.67 66.67
PPNA 0 0 0 NA 66.67
DSVL 0 0 0 NA 66.67
LS 7 2 9 0.78 0.78

Monte-Carlo

The second round of the election was then simulated \(5\times 10^{4}\) times. Essentially a weighted coin was flipped for each constituency in each simulated election.

The probability of the leader winning is given by the adjusted probabilities we have calculated — if they lose, the challenger wins the seat.

The many simulations were then compiled to see how many seats on average the party will win — the “expected” number of seats.

Because these are essentially the sum of many independent probability distributions, the calculations could be done without running the Monte Carlo.

A future project may be to rewrite this so that the model can be calculated exactly, letting me adjust the probabilities more quickly, and seeing if I can get better fit across the years (including for 2024.)

Code
#Join on the probabilities

competitors_part_B <- linear_wide_multiyear %>%
  filter(year==2024, !is.na(constituency_number) ) %>%
  mutate(Acronym_1 = if_else(is.na(Acronym_1), "Independent", Acronym_1),
        Acronym_2 = if_else(is.na(Acronym_2), "Independent", Acronym_2)) %>%
  filter(!(constituency_number %in% expected_winners_part_A$constituency_number )) %>%
  select(constituency_number, constituency,Acronym_1, Acronym_2) %>%
  mutate(Constituency= glue('{formatC(constituency_number,width=2,flag="0")}. {constituency}'))

mc_basis <- left_join(competitors_part_B,
                      round_II_probability %>%
                        select(Party,Probability) %>%
                        mutate(Probability=Probability/100),
                      by=c("Acronym_1"="Party"))

election.sim <- function() {
    mc_basis %>%
    mutate(result = if_else(runif(n=1)<Probability, Acronym_1, Acronym_2)) %>%
    select(-Acronym_1,-Acronym_2,-Probability,-Constituency) %>%
    ungroup()
}

election.tallySeats <- function( x ) {
  left_join(Round_II_Parties_2024,
            x %>% group_by(result) %>%
            tally() %>%
            rename(Party=result), by=c("Party")) %>%
    mutate(Seats=replace_na(n,0)) %>%
    select(-n)
}

election.tallySeatsWide <- function( x ) {
  left_join(Round_II_Parties_2024,
            x %>% group_by(result) %>%
            tally() %>%
            rename(Party=result), by=c("Party")) %>%
            mutate(Seats=replace_na(n,0)) %>%
            select(-n) %>%
            pivot_wider(names_from="Party",values_from="Seats")
}

load("electionsMCruns-2024.Rdata")

Predicted outcome

This was my expected outcome.

Table

Code
Summary_Part_B <- tallies %>%
  mutate(ExpectedSeats = if_else(is.na(ExpectedSeats), 0, ExpectedSeats)) %>%
  mutate(Rounded=round(ExpectedSeats))
  
  
SummarySummaryPartB <- Summary_Part_B %>%
  summarise(Party="All", `ExpectedSeats`=sum(`ExpectedSeats`),`Rounded`=sum(`Rounded`))
  
total_expected <- first_round_parl_data %>% select(-x,-y,-row,-theta) %>% unique() %>%
  full_join(Summary_Part_B, by=c("party_short"="Party")) %>%
  left_join(Part_A_Parties_2024 %>%rename("Expected First Round Winners"=n), by=c("party_short"="Party")) %>%
  replace_na(list(party_long="Unknown", party_short="Independent","Expected First Round Winners"=0,
                         seats=0, ExpectedSeats=0,Rounded=0)) %>%
  relocate("Expected First Round Winners", .before=ExpectedSeats) %>%
  filter(ExpectedSeats > 0 | seats > 0) %>%
  rename("Party (Lithuanian)"=party_long, "Party"="party_short",
         "First Round Seats"="seats", "Second Round Seats (expected)"=ExpectedSeats,
         "Second Round Seats (rounded)" = Rounded) %>%
  mutate(Total = `First Round Seats`+`Expected First Round Winners`+`Second Round Seats (rounded)`) %>%
  mutate(`Party (Lithuanian)` = case_when (
    Party == "LP" ~ "Laisvės partija",
    Party == "LT" ~ "Partija „Laisvė ir teisingumas“",
    .default = Party
  ) 
         ) %>%
  arrange(desc(Total))
kable(total_expected, digits=2)
Party (Lithuanian) Party First Round Seats Expected First Round Winners Second Round Seats (expected) Second Round Seats (rounded) Total
LSDP LSDP 20 2 18.52 19 41
TS-LKD TS-LKD 18 3 19.27 19 40
PPNA PPNA 15 0 5.89 6 21
DSVL DSVL 8 0 3.05 3 11
LVŽS LVŽS 6 0 4.80 5 11
LS LS 8 0 1.28 1 9
Independent Independent 1 0 2.92 3 4
LLRA LLRA 2 0 1.03 1 3
Laisvės partija LP 0 0 0.79 1 1
Partija „Laisvė ir teisingumas“ LT 0 0 0.46 0 0

Plot

Code
parl_data_expected <- total_expected %>%
  rename(party_long=`Party (Lithuanian)`, party_short=Party, seats=Total) %>%
  mutate(party_short = factor(party_short, levels=ordered_party_list_all_years,
                              ordered = TRUE )) %>%
  mutate(plot_order = party_short) %>%
  arrange(party_short)


expected_round_parl_data <- parliament_data(
  election_data = parl_data_expected,
  type="thirdcircle",
  party_seats = parl_data_expected$seats,
  plot_order = parl_data_expected$party_short,
  parl_rows = 6)

ggplot(expected_round_parl_data, aes(x=x, y=y, colour=party_short)) +
  geom_parliament_seats(size=4) +
  theme_ggparliament() +
  scale_colour_manual(values = party_colours_names) +
  guides(colour = guide_legend(title=NULL)) +
  labs( title="Expected outcome of 2024 Seimas elections",
        subtitle="Probabilistic simulation based on past party results",
        caption="Calculations and graph by Richard Martin-Nielsen\nDetails at http://projects.martin-nielsen.ca/OneFortyOne")

Interim results

Here are the interim results as taken from VRK at 00:30 Vilnius time on October 28, 2024.

Code
parl_data_interim <- interim_outcomes %>%
  mutate( party_short=Party, party_long=party_short,seats=`Interim Results`) %>%
  mutate(party_short = factor(party_short, levels=ordered_party_list_all_years,
                              ordered = TRUE )) %>%
  mutate(plot_order = party_short) %>%
  arrange(party_short)

interim_parliament_data <- parliament_data(
  election_data = parl_data_interim,
  type="thirdcircle",
  party_seats = parl_data_interim$seats,
  plot_order = parl_data_interim$party_short,
  parl_rows = 6)

ggplot(interim_parliament_data, aes(x=x, y=y, colour=party_short)) +
  geom_parliament_seats(size=4) +
  theme_ggparliament() +
  scale_colour_manual(values = party_colours_names) +
  guides(colour = guide_legend(title=NULL)) +
  labs( title="2024 Seimas elections - Interim results",
        caption="Interim data from VRK.lt as of 23:00 2024-10-27\nDetails at http://projects.martin-nielsen.ca/OneFortyOne")

If I rearrange the parties to look at a possible LSDP-led coalition including the DSVL and LVŽS, it looks as though it could make a government.

Code
interim_coalition_table <- interim_outcomes %>%
  mutate( party_short=Party, party_long=party_short,seats=`Interim Results`) %>%
  mutate(party_short = factor(party_short,
                              levels=c("PPNA", "LLRA", "LT", "PLT",
                                       "NS", "Independent", "TS-LKD", "LP",
                                       "LS", "LVŽS", "DSVL", "LSDP"),
                              ordered = TRUE )) %>%
  mutate(plot_order = party_short) %>%
  arrange(party_short)

coalition_parliament_data <- parliament_data(
  election_data = interim_coalition_table,
  type="thirdcircle",
  party_seats = interim_coalition_table$seats,
  plot_order = interim_coalition_table$party_short,
  parl_rows = 6)

ggplot(coalition_parliament_data, aes(x=x, y=y, colour=party_short)) +
  geom_parliament_seats(size=4) +
  draw_majoritythreshold(n = 71, 
                         linesize = 0.5,
                         type = 'semicircle')  +
  theme_ggparliament() +
  scale_colour_manual(values = party_colours_names) +
  guides(colour = guide_legend(title=NULL)) +
  labs( title="2024 Seimas elections - Interim results",
        caption="Interim data from VRK.lt as of 23:00 2024-10-27\nDetails at http://projects.martin-nielsen.ca/OneFortyOne")

Histograms of Seat Allocation

Histograms can show the distribution of the different numbers of seats for each party. The lump being further right means that the party is likely to win more seats. A narrow lump indicates that the party’s chances are limited in a tight range, usually because it is only competing for a limited number of seats in the second-round.

Having looked at all the numbers, I’m going to focus on the histograms for LSDP and TS-LKD.

Code
first_round_lookup <- first_round_parl_data %>% select(-x,-y,-row,-theta) %>% unique() %>%select(party_short, seats)

TalliedTotalSeats <- TalliedSeatsWide %>% mutate(MCrun = row_number(), .before=everything()) %>% 
  #head(5) %>%
  pivot_longer(cols=DP:LS,
               names_to="party_short", values_to = "MCseats") %>%
  left_join(first_round_lookup %>%rename(FirstRoundSeats=seats), by=c("party_short")) %>%
  replace_na(list(MCseats=0,FirstRoundSeats=0)) %>%
  mutate(total = MCseats+FirstRoundSeats) %>%
  rename(Party=party_short,Seats=total)

PositiveSeatCounts <- TalliedTotalSeats %>%
  group_by(Party) %>%
  summarise(Seats = sum(Seats)) %>%
  filter(Seats>0)

TalliedTotalSeatsWide <- TalliedTotalSeats %>%
  select(-MCseats, -FirstRoundSeats) %>%
  group_by(MCrun) %>%
  pivot_wider(names_from=Party, values_from = c(Seats)) %>%
  ungroup() %>%
  select(-MCrun)

party_colour_strips <- strip_themed(
 background_x = elem_list_rect(fill = c("#DF2F37", "#254F96")),
 text_x = elem_list_text(colour = "white",
                             face = c("bold", "bold")))
 
TalliedTotalSeats %>%
  filter(Party %in% c("TS-LKD", "LSDP")) %>%
  ggplot(aes(Seats, fill = Party)) + theme_light() +
  facet_wrap2(~Party,nrow = 1, strip = party_colour_strips ) +
  geom_bar(aes(y=(..count..)/runs),width=1) +
  scale_fill_manual(values = party_colours_names) +
  #guides(fill = guide_legend(title=NULL)) +
  guides(fill = "none") +
  geom_vline(xintercept=52, linetype=2 ) +
  geom_vline(xintercept=28, linetype=2) +
  scale_y_continuous(labels=scales::label_percent())

These dashed lines indicate the interim result seat values for the LSDP (52) and the TS-LKD (28): clearly my tails need to be wider!

Conclusions

I was relatively confident very early this morning that my model would work about as well in 2024 as it did in 2020.

This wasn’t the case. When there are more data available (including vote counts by constituency) I’ll have a closer look to see what I may have missed.

I’m curious to look at where the expectation of a win or loss was upset by actual outcomes, and if there are things which may be correlated to that (whether it may be voter gaps between leader and challenger, or the identities of the parties involved). I’m also curious to look at seeing whether participation in the government can help predict success in elections. In this round, there were significant reversals of fortune for the LSDP, TS-LKD, and LVŽS - and the LP will no longer be represented in parliament.

I think I will try seeing if I can build a version of this code which avoids the Monte Carlo approach and can calculate expected values and distributions exactly. This may make it faster to see how the model could be tuned to be ready for 2028. And I may go back to some of my other experiments looking at constituency trends and party-specific trends. A month ago I had comparable data on three Lithuanian parliamentary elections, and soon I will have data for four, which may also help determine if 2016 was an outlier, or if 2016 is like 2024, and 2012 is like 2020.

References and credits

Data is from the Central Election Commission of the Republic of Lithuania, particularly their Elections home page (Rinkėjo puslapis) which presents historical electoral data in a consistent and open way.

Hemicycle charts were created using the ggparliament package.

All analysis was done with R, mostly using the tidyverse collection of packages.

Code
interim_results <- tribble (
  ~party, ~interim_seats,
  "PLT", 1,
  "DSVL", 6,
  "PPNA", 5,
  "TS-LKD", 10,
  "LS", 4,
  "LLRA", 1,
  "NS", 1,
  "LSDP", 32,
  "LVŽS", 2,
  "Independent", 1
)

interim_outcomes <- full_join(total_expected, interim_results, by=c("Party"="party")) %>%
  replace_na(list(`First Round Seats`=0, `Expected First Round Winners` = 0, interim_seats=0,
             `Second Round Seats (expected)` = 0, `Second Round Seats (rounded)`=0, Total=0)) %>%
  mutate(interim_totals= interim_seats + `First Round Seats`) %>%
  select(Party, "Predicted Outcome"=Total, "Interim Results" = interim_totals ) %>%
  mutate(Difference=`Interim Results`-`Predicted Outcome`)
to_tribble(interim_outcomes)
kable(interim_outcomes)

Party key

Party Name
DSVL Demokratų sąjunga „Vardan Lietuvos“
Independent Independent
LP Laisvės partija
LS Liberalų sąjūdis
LLRA Lietuvos lenkų rinkimų akcija - Krikščioniškų šeimų sąjunga
LSDP Lietuvos socialdemokratų partija
LVŽS Lietuvos valstiečių ir žaliųjų sąjunga
LT Partija „Laisvė ir teisingumas“
PPNA Politinė partija „Nemuno Aušra“
TS-LKD Tėvynės sąjunga - Lietuvos krikščionys demokratai