How to read this file

This is a replication file. It contains every line of R code needed to reproduce the tables and figures of Chapter 13, together with an explanation of what each line does. The code is exactly the code we ran for the book; nothing has been simplified.

Chapter 13 explores whether electoral rules that incentivize party members to distinguish themselves from their copartisans — those with high Average Personalism (AP) — affect observed levels of party unity. We describe the sources of party unity, distinguishing between having copartisans with shared preferences and the ability to enforce discipline where it might not otherwise occur. We then make the case for why connections through social media are particularly suitable for looking for the effects of electoral incentives. The chapter finds that decisions to amplify in-party and out-party messages by retweeting them on Twitter (now X) are positively associated with AP.

Why retweets, and why this chapter looks unlike the others. The usual measure of party unity is roll-call voting, but roll calls are a poor instrument: they are selected strategically, they are unavailable in many chambers, and a party leader can enforce a unanimous vote that conceals real disagreement. Retweeting is voluntary and unwhipped, so it reveals affinity rather than compliance.

That choice makes this a network chapter. The unit is not a legislator but a tie between two legislators, and the data are adjacency matrices rather than rectangular tables. Hence the unfamiliar package list below — igraph, sna, ggraph for handling and drawing networks, and amen for the statistical model.

The measure the chapter builds is disunity: the extent to which legislators retweet across party lines rather than within them. Watch the sign as you read — a rise in the outcome means less unity.

If you have never used R. Click the grey Code button above any block to show or hide the code. Every line beginning with # is a comment, ignored by R and written purely for you. Conventions used throughout:

  • <- is R’s assignment arrow; x <- 5 stores 5 under the name x.
  • %>% is the “pipe”: a %>% f() is another way of writing f(a).
  • df$col pulls the column col out of the data frame df.
  • list() holds several objects at once; mylist[[3]] retrieves the third.
  • A model formula like y ~ x reads “y is explained by x”.

Setup

This is the slowest chapter, and it is the one chapter where caching is worth considering. knitr can store a slow block’s results and reuse them on the next knit, via the cache=TRUE chunk option. As shipped, no block here uses it: every block runs on every knit, so what the page prints is always what the code currently says.

That default is deliberate. knitr invalidates a stored result only when that block’s own code changes — not when a function or dataset the block depends on is edited elsewhere in the file. A stale cache therefore produces output that looks current and is not; one did exactly that in this project, concealing a real correction to the code in chapter 7 for several runs. The dependson and autodep options exist to close that gap, but they pass invalidation between cached blocks only, and here the blocks depended on are not themselves cached.

If the compile time becomes impractical, five blocks in this chapter are the ones worth storing: processing-data, Figure-13.4, figure-13.5, figure-13.7 and figure-13.8. Add cache=TRUE to those headers. (Skip fit-network-models: it is eval=FALSE, so there is nothing to store.)

The one rule to follow if you do: processing-data builds the objects the four figure blocks read. Editing it will not refresh them, because knitr does not know they are connected. Delete the 13PartyUnity_cache folder after any edit upstream of a figure, and let the whole chapter run again.

A note on masking. Several of these packages define functions with the same names as dplyr’s — sna and igraph in particular. Because tidyverse is loaded last, its versions win, but the reverse would break the pipelines below. Where the risk is real the code writes dplyr::select() and dplyr::filter() with the namespace spelled out.

# Load relevant libraries
library(igraph) # network construction and manipulation
library(Matrix) # sparse matrices -- retweet networks are mostly zeros
library(sna) # social network analysis routines
library(intergraph) # converts between igraph and sna/network formats
library(furrr) # parallel versions of purrr's map functions
library(progressr) # progress bars for the long-running network fits
library(RColorBrewer) # colour palettes for the network figures
library(gbm) # gradient boosting machines: supply the AP and TDE predictions
library(amen) # Additive and Multiplicative Effects models for networks --
# the estimator behind the disunity scores
library(texreg) # formats regression results into tables
library(ggraph) # grammar-of-graphics plotting for networks
library(lme4) # multilevel / mixed-effects models
library(broom.mixed) # tidies mixed-model output for tables
library(mice) # multiple imputation; pools across the five datasets
library(tidyverse) # bundle of data-handling packages; loaded last on purpose

Introduction

Chapter 13 investigates party unity, a key aspect of legislative politics that reflects how closely party members coordinate their actions and messaging. Building on previous chapters that analyzed electoral system incentives and their impact on candidate behavior and legislative institutions, this chapter focuses on how these incentives influence cohesion within parties.

To capture party unity beyond traditional roll-call votes, the chapter analyzes retweet networks among Members of Parliament (MPs) across 24 countries. This novel approach offers insights into informal political alignment by measuring how often MPs publicly support or endorse their party colleagues’ messages on social media. By linking these patterns to Average Personalism (AP)—our measure of electoral incentives encouraging individualistic behavior—the chapter reveals how different electoral systems shape intraparty cohesion.

The results show a nuanced relationship: increasing personalistic incentives tend to reduce party unity, but very high levels of personalism can paradoxically strengthen it. This suggests that highly personalistic systems may foster alternative forms of cohesion despite greater individualism. This chapter thus deepens our understanding of the complex dynamics within parties and how electoral design influences political collaboration.

Data processing

The data processing for this chapter takes a bit longer than usual, because we have to fit a series of country-specific network models. These models, known as additive and multiplicative effects models (or AME models; see Hoff 2019) learn latent positions of MPs in a 1-dimensional space using their re-tweeting activity. With these, we can then compute how close to their party center each MP is, which forms the basis of our MP-level unity measure. We merge these network-based measures with other country, party, and MP specific predictors of interest.

This script prepares the core data used in Chapter 13’s analysis of social media behavior as a proxy for party unity. It merges retweet data with metadata on MPs and party-level variables, restricts the data to within-country retweeting between MPs, and builds the foundation for later network analysis (e.g., disunity scores).

# No setwd() is needed anywhere in this chapter: paths are relative to the
# repository root, which is where this document sits.

# Load retweet data: each row is a retweet from one MP to another
full_df <- read.csv(
  file = "data/ch13/csv/rtnet_sn.csv",
  colClasses = c("src_screen_name" = "character", "rt_screen_name" = "character")
)


# Load metadata on legislators (social media identifiers and other info)
meta <- as_tibble(read.csv("data/ch13/csv/metaFull.csv", colClasses = c("uid" = "character")))

# Load party ID data and keep only member_id and party id (used for matching)
party_ln_data <- as_tibble(read.csv("data/ch13/csv/members_2021_04_28.csv", colClasses = c("uid" = "character"))) %>%
  dplyr::select(member_id, mp_party_id) %>%
  distinct()

# Merge metadata with party ID info and filter out legislators without known party
meta <- meta %>%
  left_join(party_ln_data, multiple = "first") %>%
  dplyr::filter(!is.na(mp_party_id)) %>%
  # Add manifesto and seat share data (MPDataset)
  left_join(
    read_csv("data/ch13/csv/MPDataset_MPDS2024a.csv") %>%
      mutate(elecyear = as.numeric(substr(date, 1, 4))) %>%
      mutate(perseat = absseat / totseats) %>%
      dplyr::select(mp_party_id = party, rile, totseats, perseat),
    multiple = "last"
  ) %>%
  # Add party-level government and family information (PPEG data)
  left_join(
    read_csv("data/ch13/csv/ppeg_comb_2024v1.csv") %>%
      dplyr::select(mp_party_id = cmp, gov_party, parfam = cmp_parfam) %>%
      mutate(gov_party = if_else(gov_party == "yes", 1, 0)),
    multiple = "last"
  )


# Load contextual country-level variables (e.g., democracy, presidentialism)
load("data/ch13/RData/merged_data.RData")
# Keep only the latest entry per country (to avoid duplicates)
bfc_mag <- merged_files %>%
  dplyr::select(country, start_year, democracy, regionalism, presidentialism) %>%
  mutate(max_year = max(start_year), .by = start_year) %>%
  dplyr::filter(start_year == max_year) %>%
  dplyr::select(country, start_year, democracy, regionalism, presidentialism) %>%
  distinct()

# Remove large object to save memory
rm(list = c("merged_files"))

# Convert full_df to tibble and merge country info for sender and retweeted MP
full_df <- as_tibble(full_df) %>%
  left_join(dplyr::select(meta, c(src_screen_name = uid, ctry_sender = country)), relationship = "many-to-many") %>%
  left_join(dplyr::select(meta, c(rt_screen_name = uid, ctry_rt = country)), relationship = "many-to-many") %>%
  # Keep only retweets where both sender and receiver are matched to countries
  dplyr::filter(!is.na(ctry_sender) & !is.na(ctry_rt)) %>%
  # Group by MP dyads and count total retweets (edge weight), keeping country info
  group_by(src_screen_name, rt_screen_name) %>%
  summarize(
    edge = n(),
    ctry_sender = first(ctry_sender), ctry_rt = first(ctry_rt)
  ) %>%
  # Keep only retweets that occur within the same country
  dplyr::filter(ctry_sender == ctry_rt) %>%
  # Remove self-retweets
  dplyr::filter(src_screen_name != rt_screen_name)


# Filter and retain only metadata for MPs who are involved in at least one retweet (sender or target)
meta <- meta %>%
  dplyr::select(vertex = uid, party, country, member_id, magnitude, rile, parfam, perseat, totseats, gov_party) %>%
  dplyr::filter((vertex %in% full_df$src_screen_name) | (vertex %in% full_df$rt_screen_name)) %>%
  distinct(vertex, .keep_all = TRUE)

This block iterates over each country to estimate party disunity scores using latent positions derived from retweeting behavior, operationalized through 1D AME network models, and stores the results for later analysis (e.g., pooled regressions in Table 13.1).

This block is switched off (eval=FALSE) and will not run. Fitting an AME model for each of 24 countries takes hours. The results were computed once and saved; the merge-all-data chunk below reads them back from data/ch13/rds/. The code is kept here so the procedure is fully documented, not because it needs to run.

To re-fit from scratch, set eval=TRUE and expect a long wait.

What an AME model does, and why the chapter needs one. Ordinary regression assumes observations are independent. In a network they plainly are not: if A retweets B, that tells us something about whether B retweets A, and popular legislators attract retweets from everyone.

An Additive and Multiplicative Effects model separates those patterns into parts — how much each legislator sends and receives in general (the additive effects), and a latent position for each legislator such that people close together retweet each other more (the multiplicative effect).

It is that latent position, net_model$U, that the chapter is after. Once every legislator has a position, the distance from their own party’s centre becomes a measure of how far they stand apart from their copartisans — the individual-level disunity score used from here on.

## Function to analyze retweet networks for a given country

# Defines a recipe applied separately to each country: build that country's
# retweet network, fit the AME model to it, and return one row per legislator.
analyze_nets <- function(cty, metadata, rt_df) {
  # Subset the retweet data to the given country
  cty_df <- rt_df %>% dplyr::filter(ctry_sender == cty)

  # Subset metadata for MPs in the given country
  cty_meta <- metadata %>% dplyr::filter(country == cty)

  # Create an igraph object from country-specific retweet data
  rt_net <- graph_from_data_frame(
    cty_df %>% dplyr::select(src_screen_name, rt_screen_name, weight = edge, Country = ctry_sender) %>%
      mutate(weight = log(weight)), # Log-transform the retweet frequency
    vertices = cty_meta %>% dplyr::select(vertex, Party = party)
  ) # Attach party info to each node


  # Create a sociomatrix (adjacency matrix) with edge weights
  # A sociomatrix is a square table with one row and one column per legislator;
  # the cell at [i, j] records how much i retweeted j. This is the format the
  # ame() function expects, as opposed to the edge list used above.
  socmat <- as_adjacency_matrix(rt_net, sparse = FALSE, attr = "weight")

  # Fit a 1-dimensional Additive and Multiplicative Effects (AME) model
  net_model <- ame(socmat,
    family = "nrm", # Normal response model (appropriate for weighted networks)
    symmetric = FALSE, # Retweet networks are directional
    R = 1, # One latent dimension (for position estimation)
    burn = 2000, # Burn-in iterations
    nscan = 500, # Sampling iterations
    odens = 1, # Output every iteration
    print = FALSE, # Suppress console printing
    plot = FALSE
  ) # Suppress plotting

  # Build dataframe with estimated latent positions and covariates
  df <- data.frame(
    pos = net_model$U, # Estimated latent positions (unity dimension)
    id = cty_meta$vertex, # MP ID
    party = cty_meta$party, # Party ID
    magnitude = cty_meta$magnitude, # District magnitude
    parfam = cty_meta$parfam, # Party family
    party_ideo = cty_meta$rile, # Party ideology (RILE index)
    perseat = cty_meta$perseat, # % of seats held
    totseats = cty_meta$totseats, # Total seats
    gov_party = cty_meta$gov_party, # Government party indicator
    country = cty_meta$country
  ) # Country name

  # Compute party-level average latent positions
  df <- df %>%
    group_by(party) %>%
    mutate(party_mean = mean(pos)) %>%
    ungroup() %>%
    # Calculate normalized squared distance from party average (disunity score)
    mutate(dist = ((pos - party_mean)^2) / sd(pos)) %>%
    # Keep relevant variables with clear names
    dplyr::select(id, country, party, dist, pos, party_mean,
      M = magnitude, parfam, party_ideo, perseat,
      totseats, gov_party
    )
  # Save the resulting dataframe to file
  saveRDS(df, file = paste0("data/", cty, ".rds"))
}

## Analyze networks for all countries in the dataset
countries <- unique(full_df$ctry_sender)

# Use parallel processing for faster execution
plan(multisession, workers = 8)

# Progress-aware future map to iterate over all countries
with_progress({
  p <- progressor(steps = length(countries)) # Initialize progress bar
  future_map(countries,
    ~ {
      analyze_nets(.x, metadata = meta, rt_df = full_df)
      p()
    } # Run function and update progress
    ,
    .options = furrr_options(seed = 831213)
  )
})

This block finalizes the dataset used in the regression models from Table 13.1 by merging country-level disunity scores with system-level variables and electoral rules, and resolving edge cases and harmonizing definitions (e.g., thresholds, formula types) to ensure consistency across countries.

# Load and merge disunity data from all countries

all_data_orig <- list.files(path = "data/ch13/rds/", pattern = "*.rds", full.names = TRUE) %>%
  map_dfr(readRDS) # Read and bind all into a single dataframe

# Merge with system-level variables (democracy, presidentialism, etc.)
all_data <- all_data_orig %>%
  inner_join(bfc_mag, multiple = "last") %>%
  dplyr::select(
    id, country, party, dist, M, democracy, regionalism, presidentialism,
    party_ideo, parfam, perseat, gov_party, totseats, pos, party_mean
  )

# Merge with electoral system component rules
realCases <- read.csv("data/ch13/csv/realCases.csv") %>%
  dplyr::select(country, year, formula, # Electoral formula
    ballot_type, new.nvotes, # Ballot structure and number of votes
    pool_level,
    threshold = threshold_tier1 # Pooling level and electoral threshold
  ) %>%
  # Harmonize country name for the U.S.
  mutate(country = if_else(country == "United States of America",
    "United States", country
  )) %>%
  # Keep only countries in disunity data
  dplyr::filter(country %in% all_data$country) %>%
  # Keep only the most recent election year for each country
  mutate(filter = if_else(year == max(year), 1, 0), .by = country) %>%
  dplyr::filter(filter == 1) %>%
  dplyr::select(-c(filter, year)) %>%
  # Normalize thresholds: treat any positive threshold as 5%, else 0%
  mutate(threshold = if_else(threshold > 0, 0.05, 0.0)) %>%
  distinct() %>%
  # Remove duplicate Greece case with inconsistent ballot_type = "open"
  dplyr::filter(!(country == "Greece" & ballot_type == "open"))


# Fix known inconsistencies manually
realCases <- realCases %>%
  mutate(formula = case_match(country,
    c("Greece", "Italy") ~ "hare", # coded as using Hare formula
    .default = formula
  )) %>%
  mutate(threshold = case_match(country,
    "Austria" ~ 0.05, # Austria threshold is 5%
    "Greece" ~ 0.03, # Greece threshold is 3%
    .default = threshold
  ))

# Merge electoral rules with full legislator-level dataset
all_data <- right_join(realCases, all_data, by = "country")

# Drop rows with missing M (district magnitude) and finalize variable names
all_data <- all_data %>%
  tidyr::drop_na(M) %>%
  rename(Distance = dist) %>% # Rename disunity score to "Distance"
  distinct(id, .keep_all = TRUE) # Remove duplicates by legislator ID

# Manually correct component rule values for single-member districts in Germany and NZ
all_data$new.nvotes[all_data$M == 1 & all_data$country == "New Zealand" | all_data$M == 1 & all_data$country == "Germany"] <- "One"
all_data$ballot_type[all_data$M == 1 & all_data$country == "New Zealand" | all_data$M == 1 & all_data$country == "Germany"] <- "closed"
all_data$formula[all_data$M == 1 & all_data$country == "New Zealand" | all_data$M == 1 & all_data$country == "Germany"] <- "plurality"

This block is critical for generating the two key explanatory variables used in the regression models of disunity in Table 13.1:

AP_avg: captures the individual legislator’s intraparty personalization incentives TDE_avg: captures the interparty fragmentation of the party system

These are generated using GBM models trained on structural electoral features (like ballot type, formula, magnitude), and are averaged over 5 models to reduce model-specific noise.

## Predict AP and TDE scores

# Load GBM objects trained at the district level for TDE and AP
load(file = "data/shared/TDE_district_objects_t305_d15.RData")
load(file = "data/shared/AP_district_objects_t515_d7.RData")

# Assign to new variable names for clarity and to avoid overwriting other objects
totalAP.objects.district <- totalAP.objects
totalEffENP.objects.district <- totalEffENP.objects

# Remove original objects to free up memory and avoid confusion
rm(totalAP.objects, totalEffENP.objects)

# Convert input variables to factors with correct levels based on GBM training
all_data$new.nvotes <- factor(all_data$new.nvotes, levels = totalEffENP.objects.district$optimalGBM[[1]]$var.levels[[2]])
all_data$pool_level <- factor(all_data$pool_level, levels = totalEffENP.objects.district$optimalGBM[[1]]$var.levels[[3]])
all_data$ballot_type <- factor(all_data$ballot_type, levels = totalEffENP.objects.district$optimalGBM[[1]]$var.levels[[4]])
all_data$formula <- factor(all_data$formula, levels = totalEffENP.objects.district$optimalGBM[[1]]$var.levels[[6]])

# Initialize empty lists to store predicted values
## Using the district-level GBM object ##
totalEff.hat <- pers.hat <- list()

# Do we need pers.hat <- list() ?


# Predict TDE and AP for each of the 5 imputed models (ensembling for robustness)
for (i in 1:5) {
  optimalGBMIntra <- totalAP.objects.district$optimalGBM[[i]]
  optimalGBM <- totalEffENP.objects.district$optimalGBM[[i]]

  # Predict AP (intraparty personalism)
  all_data[[paste0("AP", i)]] <- predict(optimalGBMIntra, all_data,
    n.trees = optimalGBMIntra$n.trees
  )
  pers.hat[[i]] <- all_data[[paste0("AP", i)]]

  # Predict TDE (interparty fragmentation)
  all_data[[paste0("TDE", i)]] <- predict(optimalGBM, all_data,
    n.trees = optimalGBM$n.trees
  )
  totalEff.hat[[i]] <- all_data[[paste0("TDE", i)]]
}

# Compute average predicted values across the 5 imputations
all_data$TDE_avg <- Reduce("+", totalEff.hat) / 5
all_data$AP_avg <- Reduce("+", pers.hat) / 5

Networks are fun! They allow for really neat visualizations. This chapter thus contains a few more figures than our other ones, building our argument using descriptive graphical summaries.

Figure 13.1: Retweet Networks in The Netherlands, France, and Slovenia

How to read Figure 13.1. Three retweet networks drawn as graphs: each dot is a legislator, each line a retweet, and colour marks party. Position carries no units — the layout algorithm simply pulls connected legislators together.

What to look at is the shape. Where parties form tight, well-separated clumps, legislators retweet their own side and little else: high unity. Where the colours blend into one another, cross-party amplification is common: disunity. The three countries are chosen to span that range, so the figure shows the concept before any number is attached to it.

Figure 13.1 provides a visual representation of retweet networks among legislators in three countries—Netherlands, France, and Slovenia—using data from 2019. This figure is part of the chapter’s descriptive analysis exploring the extent of party unity as reflected in online social interactions. Retweeting behavior is interpreted as an expression of affinity or agreement, and the network structure allows us to visualize how cohesively legislators interact with their co-partisans versus members of other parties. By selecting countries with varying levels of Average Personalism (AP), the figure offers an initial impression of how electoral incentives may shape observed levels of party unity.

## Networks for illustrative purposes

# Define the countries to include in the comparison
cty <- c("Netherlands", "France", "Slovenia")

# Filter retweet data to only those observations where the sender is from the selected countries
cty_df <- full_df %>% dplyr::filter(ctry_sender %in% cty)

# Filter metadata for MPs in those countries
cty_meta <- meta %>% dplyr::filter(country %in% cty)

# Build igraph object:
# - Only keep retweets that occurred within the same country
# - Define edges using screen names
# - Attach country and party metadata to vertices (nodes)

rt_net <- graph_from_data_frame(
  cty_df %>%
    dplyr::filter(ctry_sender == ctry_rt) %>%
    dplyr::select(src_screen_name, rt_screen_name, Country = ctry_sender),
  vertices = cty_meta %>%
    dplyr::select(vertex, Party = party, Country = country)
)

# Set a random seed for consistent layout
set.seed(831213)

# Use ggraph to plot the network using the Fruchterman-Reingold layout algorithm
ggraph(rt_net, layout = "igraph", algorithm = "fr") +
  geom_edge_link0(edge_alpha = 0.1) + # Draw light gray edges (retweets)
  geom_node_point() + # Draw black points for each MP
  # geom_node_point(aes(color = Party)) +     # Optional: add party color
  guides(color = "none") + # Hide legend (not used here)
  facet_wrap(
    ~ factor(Country,
      levels = c("Netherlands", "France", "Slovenia")
    ),
    scales = "free"
  ) + # One panel per country
  theme_bw() + # Use clean theme
  theme( # Remove axes and gridlines for clarity
    axis.text.x = element_blank(), axis.ticks.x = element_blank(),
    axis.text.y = element_blank(), axis.ticks.y = element_blank(),
    panel.grid.major = element_blank(), panel.grid.minor = element_blank()
  ) +
  ylab("") + xlab("") # Remove axis labels

The networks in Figure 13.1 reveal meaningful variation in the cohesiveness of retweeting behavior across countries. In the Netherlands and France, MPs tend to cluster tightly in distinct groups, suggesting a high degree of within-party retweeting and thus stronger levels of observed party unity. In contrast, Slovenia’s network appears more diffuse, with weaker clustering and more cross-party connections. As noted in the chapter, these patterns are consistent with the idea that electoral incentives—specifically those captured by the Average Personalism (AP) score—influence legislators’ decisions to align publicly with their co-partisans. Legislators in high-AP systems like Slovenia, where the incentives to cultivate personal reputations are stronger, are less likely to amplify their fellow party members’ messages, resulting in weaker party-centered network structures.

Figure 13.2: Within and Across Party Retweet Connections

Figure 13.2 compares the strength of retweet connections within and across parties in three countries—France, the Netherlands, and Slovenia—that span the observed range of Average Personalism (AP) values in the sample. The figure offers descriptive evidence of how electoral system incentives relate to party unity by quantifying the extent to which legislators amplify messages from co-partisans versus members of other parties. Since retweeting is treated as a form of public agreement or support, stronger within-party ties suggest more cohesive party communication. The comparison aims to illustrate how personalism affects party-centered communication patterns in public-facing, low-cost contexts like Twitter.

## Strength of connections within party vs. across party in prototypical countries

# Define the countries to plot
plot_ctr <- c("Netherlands", "France", "Slovenia")

# Filter metadata to those countries and merge with electoral variables
cty_meta <- meta %>%
  dplyr::filter(country %in% plot_ctr) %>%
  left_join(all_data %>% dplyr::select(vertex = id, country, AP = AP_avg, TDE = TDE_avg, regionalism, democracy, presidentialism)) %>%
  dplyr::filter(complete.cases(.))

# Filter retweet data to dyads where both sender and receiver are in selected metadata
cty_df <- full_df %>%
  dplyr::filter(src_screen_name %in% cty_meta$vertex & rt_screen_name %in% cty_meta$vertex) %>%
  dplyr::filter(ctry_sender %in% plot_ctr)

# Create a graph from dyads with weight = number of retweets
rt_net <- graph_from_data_frame(
  cty_df %>%
    dplyr::select(src_screen_name, rt_screen_name, weight = edge),
  vertices = cty_meta %>%
    dplyr::select(vertex, Party = party, country, magnitude, parfam, perseat, AP, TDE)
)


# Extract node and edge information from the graph
node_list <- igraph::as_data_frame(rt_net, what = "vertices")
edge_list <- igraph::as_data_frame(rt_net, what = "edges") %>%
  inner_join(node_list %>% dplyr::select(name, Party, country), by = c("from" = "name")) %>%
  inner_join(node_list %>% dplyr::select(name, Party, country), by = c("to" = "name")) %>%
  dplyr::filter(country.x == country.y) %>%
  mutate(country = country.x) %>%
  dplyr::select(-c(country.x, country.y)) %>%
  mutate(in_group = ifelse(Party.x == Party.y, "Within Party", "Across Parties"))

# Plot the distribution of tie strength by country and type of tie
ggplot(edge_list, aes(x = country, y = log(weight), fill = factor(in_group))) +
  ylab("Strength of connection\n(log nr. re-tweets)") +
  xlab("") +
  scale_fill_brewer(name = "Type of Connection", palette = "Greys") +
  geom_boxplot(outliers = FALSE) +
  theme_bw()

Figure 13.2 reveals how the strength of retweet connections differs within and across parties in France, the Netherlands, and Slovenia. In all three countries, as expected, within-party ties tend to be stronger than across-party ones. However, the extent of this difference varies. In low-AP countries like the Netherlands and France, retweeting remains more concentrated within parties, indicating higher unity. In contrast, Slovenia—one of the highest-AP countries in the sample—shows less separation between within- and across-party ties. This pattern suggests that legislators in high-AP systems are more likely to engage with out-party content, reflecting a lower degree of party-centered communication. These findings are consistent with the chapter’s broader argument that personalistic electoral incentives can undermine observed party unity, especially in public-facing settings where discipline is minimal.

Figure 13.3: Network of Largest Parties in Germany

Check the margins. The par(mar = c(.1, .1, 1.9, .1)) call in this block leaves almost no space on three sides — margins are given in the order bottom, left, top, right, so only the top has room for a title. If node labels or panel titles appear clipped in the knitted page, this is the line to adjust. No R code has been changed.

Figure 13.3 visualizes the retweet network among members of the three largest German parties—SPD, CDU/CSU, and The Left Party—using directed ties based on 2019 Twitter data. This figure serves to illustrate how patterns of communication on social media reflect degrees of party unity. Legislators are connected by retweet activity, and the clustering of nodes indicates the extent to which co-partisans form discrete communication communities.

# Filter metadata to include only German MPs from SPD, CDU/CSU, and The Left Party
de_meta_2 <- all_data %>%
  dplyr::filter(country == "Germany") %>%
  ungroup() %>%
  dplyr::select(id, party) %>%
  dplyr::filter(party %in% c("SPD", "CDU/CSU", "The Left Party")) %>%
  distinct()

# Filter retweet dyads between these MPs within Germany
de_df_2 <- full_df %>%
  ungroup() %>%
  dplyr::filter((ctry_sender == "Germany")) %>%
  dplyr::filter((src_screen_name %in% de_meta_2$id) & (rt_screen_name %in% de_meta_2$id))

# Create graph object: directed network with retweet weights
rt_net_de <- graph_from_data_frame(de_df_2 %>% dplyr::select(src_screen_name, rt_screen_name, weight = edge),
  vertices = de_meta_2 %>% dplyr::select(vertex = id, party) %>% distinct(vertex, .keep_all = TRUE)
)

# Define grayscale color mapping by party
vertex.colors <- c("SPD" = gray(0.8), "CDU/CSU" = gray(0.5), "The Left Party" = gray(0.2))

# Set plotting parameters: remove margins, set title space
par(mar = c(.1, .1, 1.9, .1))

# Set seed for reproducible layout and compute Fruchterman-Reingold layout
set.seed(831213)
de_layout <- layout.fruchterman.reingold(rt_net_de)

# Plot the graph
plot(rt_net_de,
  layout = de_layout,
  vertex.shape = "circle", # Simple circular nodes
  vertex.size = 2, # Small node size
  vertex.color = vertex.colors[V(rt_net_de)$party], # Color by party
  edge.color = "lightgray", # Light gray directed edges
  edge.arrow.size = 0.15, # Small arrow heads
  vertex.label = NA, # No text labels
  edge.curved = 0.2, # Slight curvature in edges
  main = "Re-tweet Network\nLargest German Parties", # Title
  asp = FALSE
)


# Add manual legend in top-left corner
legend("topleft",
  legend = c("SPD", "CDU/CSU", "The Left Party"),
  pch = c(19),
  col = vertex.colors,
  bty = "n",
  title = ""
)

Figure 13.3 reveals that members of SPD, CDU/CSU, and The Left Party in Germany form relatively distinct communication clusters on Twitter, indicating substantial party unity. Each group of legislators appears as a separate community within the retweet network, suggesting that co-partisans mostly share and amplify messages from their own party. However, the layout also shows that some legislators are positioned closer to members of other parties than to their own, suggesting weaker intra-party connectivity for those individuals. These patterns align with the chapter’s claim that the spatial arrangement of legislators in retweet networks can be used to infer the degree of public-facing unity within and across parties.

Figure 13.4: Disunity Induced by Legislators in Two Hypothetical Parties

Constructed, not observed. As in Figure 8.1 of the ideology chapter, this figure builds two artificial parties in order to show exactly what the disunity measure responds to. Because the true structure is known by construction, the figure demonstrates that the measure behaves as intended before it is turned loose on real legislators.

The MP-level measure of unity that results from fitting the AME models is a generalization of a more common measure of unity, the eigenvector centrality of each legislator relative to her party. Here and in the following figure, we build this intuition, present centrality measures using target plots, and use these to translate the notion of centrality into a core-periphery representation of MPs: more united parties will have very tight cores, while more disunited parties will have loser cores. Figure 13.4 presents a visual comparison between two hypothetical parties—one that is maximally united and another that is maximally divided—to illustrate how the chapter’s network-based measure of disunity works in practice.

## Example for hypothetical parties
set.seed(831213)

# Create a maximally connected party (complete graph of 30 nodes)
full_c <- make_full_graph(30)

# Remove all but one of the edges from node 1, simulating a single disconnected outlier
full_c <- delete_edges(full_c, incident(full_c, 1)[-1])

# Create a maximally divided version: a spanning tree from the same nodes
not_c <- subgraph.edges(full_c, sample_spanning_tree(full_c))

# Convert igraph objects to 'network' class objects
full_c_g <- asNetwork(full_c)
not_c_g <- asNetwork(not_c)

# Combine both graphs into a block-diagonal matrix and estimate 1D latent positions using AME model
net_model_both <- ame(as.matrix(bdiag(as.matrix(full_c_g), as.matrix(not_c_g))),
  family = "bin",
  symmetric = TRUE,
  R = 1,
  burn = 2500, nscan = 500, odens = 1, print = FALSE, plot = FALSE
)

# Create party labels and compute disunity (squared distance from party mean, normalized)
sym_parties <- data.frame(
  pos = c(net_model_both$U),
  party = rep(c("A", "B"), each = 30)
) %>%
  group_by(party) %>%
  mutate(party_mean = mean(pos)) %>%
  ungroup() %>%
  mutate(dist = ((pos - party_mean)^2) / sd(pos))

# Plot layout: 2 rows x 2 columns
par(mfrow = c(2, 2))

# Top left: unified network
par(mar = c(.1, .1, .9, .1))
plot(full_c_g, edge.col = gray(0.6, 0.5), vertex.col = "black", main = "Maximally United")

# Top right: divided network
plot(not_c_g, edge.col = gray(0.6, 0.5), vertex.col = "black", main = "Maximally Divided")

# Bottom left: target plot for united party
par(mar = c(.1, .1, .1, .1))
gplot.target(full_c_g, -c(sym_parties$dist[1:30]),
  main = "",
  circ.lab = FALSE, circ.col = "black", vertex.col = "black",
  usearrows = FALSE, circ.lty = 1, circ.lwd = 0.2,
  edge.col = gray(0.6, 0.7), vertex.border = "white"
)

# Bottom right: target plot for divided party
gplot.target(not_c_g, -c(sym_parties$dist[31:60]),
  main = "", # eigen_centrality(not_c)$vector, main="",
  circ.lab = FALSE, circ.col = "black", vertex.col = "black",
  usearrows = FALSE, circ.lty = 1, circ.lwd = 0.2,
  edge.col = gray(0.6, 0.7), vertex.border = "white"
)

Figure 13.4 demonstrates how retweet networks can be used to visualize party unity at the individual level. The left panel shows a nearly fully connected party, where all members retweet each other except one. This dense web results in most legislators appearing near the bullseye in the target plot, reflecting low disunity. In contrast, the right panel shows a minimally connected party, where each tie is critical to maintaining cohesion. In this fragmented network, most members are located at the periphery of the target plot, indicating high disunity. This example illustrates how the chapter’s disunity metric generalizes centrality by incorporating higher-order connectivity—not just whether a legislator is well connected, but whether they are connected to others who are also central.

Figure 13.5: Party-Specific Disunity of German Legislators

This figure presents party-level target plots of German legislators based on 2019 retweet network data. These plots illustrate the party-specific disunity of MPs: nodes near the center are more central (i.e., more unified with their co-partisans), while peripheral nodes reflect higher disunity scores. The size of each node reflects the legislator’s Average Personalism (AP) score, linking observed disunity to the electoral incentives they face. The figure applies the network-based logic introduced in Figure 13.4 to real-world data, allowing us to examine how intra-party dynamics vary across parties and how personalizing incentives may influence a legislator’s position in their party’s communication network.

#### Centrality for parties in Germany

# Define a function that extracts retweet networks by party for a given country
party_net_fun <- function(plot_ctr, exclude = NULL) {
  # Filter metadata for the selected country and extract relevant variables
  cty_meta_2 <- all_data %>%
    dplyr::filter(country == plot_ctr) %>%
    ungroup() %>%
    dplyr::select(id, party, AP_avg, Distance, M) %>%
    distinct()
  # Filter retweet dyads where both MPs are from the selected country and present in metadata
  cty_df_2 <- full_df %>%
    ungroup() %>%
    dplyr::filter((ctry_sender == plot_ctr)) %>%
    dplyr::filter((src_screen_name %in% cty_meta_2$id) & (rt_screen_name %in% cty_meta_2$id))

  # Create graph object with party-level metadata
  rt_net_2 <- graph_from_data_frame(
    cty_df_2 %>%
      dplyr::select(src_screen_name, rt_screen_name, weight = edge),
    vertices = cty_meta_2 %>%
      dplyr::select(vertex = id, party, AP_avg, Distance, M) %>%
      distinct(vertex, .keep_all = TRUE) %>%
      mutate(size = log(M + 1))
  )

  # Optionally exclude some minor parties
  if (!is.null(exclude)) {
    party_names <- unique(cty_meta_2$party)[-exclude]
  } else {
    party_names <- unique(cty_meta_2$party)
  }

  # Get party sizes and keep only those with more than 3 members
  sizes <- table(cty_meta_2$party)[party_names]
  sizes <- sizes[order(sizes, decreasing = TRUE)]
  sizes <- sizes[sizes > 3]

  # Create a list of party-specific network graphs
  party_graphs <- lapply(
    names(sizes),
    \(party){
      asNetwork(induced_subgraph(rt_net_2, V(rt_net_2)$party == party))
    }
  )
  names(party_graphs) <- names(sizes)
  return(party_graphs)
}

# Apply function to Germany, excluding very small parties
de_graphs <- party_net_fun("Germany", c(5, 6, 9))

# Set layout and margins for plotting
set.seed(831213)
par(mfrow = c(2, 3))
par(mar = c(.1, .1, .9, .1))

# Loop through each party and create a target plot
for (i in seq_along(de_graphs)) {
  g <- de_graphs[[i]]

  # Plot target plot with:
  # - centrality = inverse of disunity (log-scaled)
  # - node size = scaled AP score
  gplot.target(g, -c(log((g %v% "Distance") + 1e-8)),
    main = names(de_graphs)[i],
    circ.lab = FALSE, circ.col = "black", vertex.col = "black",
    usearrows = FALSE, circ.lty = 1, circ.lwd = 0.2,
    edge.col = gray(0.5, 0.3), vertex.border = "white",
    vertex.cex = pmin(2.5, pmax(0.75, (scale(g %v% "AP_avg") + abs(min(scale(g %v% "AP_avg"))))))
  )
}

Figure 13.5 shows the disunity scores of German legislators within their parties using target plots. Legislators closer to the center of the plot are more unified (i.e., more central in their party’s retweet network), while those on the periphery contribute more to party disunity. The size of each dot reflects their AP score, showing the extent to which they face incentives to emphasize personal reputations. The figure illustrates that legislators with high AP scores are more likely to lie on the periphery of their parties. Still, the relationship is not deterministic: some high-AP MPs appear central (especially in CDU/CSU), while some low-AP MPs lie at the edges (notably in Alliance 90/The Greens). This heterogeneity highlights the importance of individual-level variation in disunity and underscores the complexity of the link between electoral incentives and observed party cohesion.

Figure 13.6: Disunity Scores Across Countries

One final descriptive look at our data: How much cross-country and within-country variation is there in terms of unity? And what is the overall relationship between unity and AP? Here, we tackle these questions using boxplots (which show the distribution of unity within countries), and sort countries based on their average level of unity. This allows us to get a sense of the general observed relationship between our AP scores and party unity, measured using re-tweeting networks.

## Summary plot of disunity across countries, sorted by average AP score

all_data %>%
  # Reorder countries by their mean AP score
  mutate(country = factor(reorder(country, AP_avg, mean))) %>%
  # Begin ggplot: boxplot of disunity per country
  ggplot(aes(x = factor(reorder(country, AP_avg, mean)), y = log(Distance + 1e-8))) +

  # Draw boxplots for each country (excluding outliers for clarity)
  geom_boxplot(outliers = FALSE) +

  # Add a smoothed median trend line across countries
  geom_smooth(
    data = all_data %>%
      mutate(country = factor(reorder(country, AP_avg, mean))) %>%
      summarize(y = median(log(Distance + 1e-8)), .by = country),
    aes(x = factor(country), y = y, group = 1),
    se = FALSE, color = "black"
  ) +

  # Label axes
  xlab("") +
  ylab("Log Dis(unity)") +
  theme_bw() +

  # Rotate x-axis labels for readability
  theme(axis.text.x = element_text(angle = 45, vjust = 1, hjust = 1))

Figure 13.6 shows the distribution of legislator-level disunity scores (in log scale) across 24 countries, sorted by their average AP scores—from least personalistic (Netherlands) to most personalistic (Slovenia). Two patterns emerge. First, there is a generally positive—but non-linear—relationship between AP and disunity, as shown by the smoothed line. Legislators in countries with more personalizing electoral systems tend to exhibit higher disunity, but the relationship weakens or even reverses at very high AP values. Second, within-country variation far exceeds between-country variation, emphasizing the need to analyze party unity at the individual level. These findings support the idea that electoral system incentives do shape observed party cohesion on social media, but also highlight that much variation in party unity stems from factors other than electoral rules.

How to read Figures 13.5 and 13.6. Figure 13.5 shows disunity scores for German legislators broken out by party — the measure at its finest grain, where you can see that parties differ and that legislators within a party differ too. Figure 13.6 zooms out to compare distributions across all countries.

Both are still descriptive. They establish that the outcome varies at the levels the argument needs it to vary — within parties, and between systems — before AP is asked to explain any of it.

Table 13.1: Models of Party (Dis)Unity

How to read Table 13.1, and mind the sign. The outcome is disunity, so a positive AP coefficient means more personalistic systems produce less party unity — legislators reaching across party lines rather than amplifying their own side.

The chapter reports something more interesting than a straight line: the relationship turns. Rising personalism reduces unity up to a point, after which very high personalism is associated with unity recovering. If the models include a squared AP term, that curvature is what it captures, and Figure 13.7 is where it becomes visible. A significant quadratic term with a negative sign on the squared coefficient is the signature of that reversal.

Table 13.1 presents results from a set of regression models estimating the relationship between electoral system incentives and observed party disunity. The outcome variable is the logged disunity score computed from retweet networks. The key predictor is Average Personalism (AP), which reflects the extent to which electoral rules incentivize candidates to cultivate personal reputations. The models also include Total Duvergerian Effect (TDE) and other party/system-level controls. Our regression models for this chapter use random intercepts by country and (perhaps most importantly) by party, as our graphical descriptive exercises indicate that most of the variation in unity happens across parties in the same country. Including the latter type of random intercept dramatically increases the amount of variation captured by our model.

## Regression ####

# Add weighting variables:
# - peractive: proportional representation of country in dataset (n/totseats)
# - scale_factor: used to weight by representation across countries

all_data <- all_data %>%
  # (!(country %in% c("Netherlands","Slovenia"))) %>% #exclude to satisfy reviewer
  mutate(peractive = n() / totseats, .by = country) %>%
  mutate(scale_factor = n() / sum(peractive))

# Model 1: Linear regression, no random effects, only AP and TDE
mod_list_1 <- list()
mod_list_1 <- lapply(
  1:5,
  \(x){
    tmp <- all_data %>%
      dplyr::filter(Distance > 0)
    tmp[["AP"]] <- tmp[[paste0("AP", x)]]
    tmp[["TDE"]] <- tmp[[paste0("TDE", x)]]
    lm(log(Distance) ~ AP + TDE, weights = 1 / (peractive), data = tmp)
  }
)

# Model 2: Mixed model with country random intercept and polynomial AP
mod_list_2 <- list()
mod_list_2 <- lapply(
  1:5,
  \(x){
    tmp <- all_data %>%
      dplyr::filter(Distance > 0)
    tmp[["AP"]] <- tmp[[paste0("AP", x)]]
    tmp[["TDE"]] <- tmp[[paste0("TDE", x)]]
    lmer(log(Distance) ~ poly(AP, 2, raw = TRUE) + TDE + (1 | country),
      weights = 1 / (peractive), data = tmp
    )
  }
)

# Model 3: Mixed model with country and party random intercepts, full controls
mod_list_3 <- list()
mod_list_3 <- lapply(
  1:5,
  \(x){
    tmp <- all_data %>%
      dplyr::filter(Distance > 0)
    tmp[["AP"]] <- tmp[[paste0("AP", x)]]
    tmp[["TDE"]] <- tmp[[paste0("TDE", x)]]
    lmer(
      log(Distance) ~ poly(AP, 2, raw = TRUE) + TDE + democracy + regionalism +
        party_ideo + presidentialism * gov_party + perseat + (1 | country) + (1 | party),
      weights = 1 / (peractive), data = tmp
    )
  }
)

# Pool coefficients across imputations using Rubin's rules
lm_combined_1 <- pool(mod_list_1)
lm_combined_2 <- pool(mod_list_2)
lm_combined_3 <- pool(mod_list_3)

# Compute approximate R² for each model
r2_1 <- cor(predict(mod_list_1[[2]]), model.frame(mod_list_1[[1]])[, 1])^2
r2_2 <- cor(predict(mod_list_2[[2]]), model.frame(mod_list_2[[1]])[, 1])^2
r2_3 <- cor(predict(mod_list_3[[2]]), model.frame(mod_list_3[[1]])[, 1])^2

# Create regression table using texreg
texreg::htmlreg(list(lm_combined_1, lm_combined_2, lm_combined_3),
  custom.coef.map = list(
    "(Intercept)" = "(Intercept)",
    "AP" = "AP",
    "poly(AP, 2, raw = TRUE)1" = "AP",
    "poly(AP, 2, raw = TRUE)2" = "AP^2",
    "TDE" = "TDE",
    "regionalism" = "Regionalism",
    "presidentialism" = "Presidentialism",
    "gov_party" = "Party In Government?",
    "presidentialism:gov_party" = "Pres. $\\times$ In Government",
    "party_ideo" = "Party Ideology",
    "perseat" = "Party Legislative Size"
  ),
  custom.gof.names = c("N imputations", "N", " ", " "),
  custom.gof.rows = list(
    "Country RI" = c("No", "Yes", "Yes"),
    "Party RI" = c("No", "No", "Yes"),
    "R$^2$" = c(r2_1, r2_2, r2_3)
  )
)
Statistical models
  Model 1 Model 2 Model 3
(Intercept) -8.13*** -14.54 -20.37***
  (0.44) (8.12) (5.77)
AP 5.80*** 51.28 53.08*
  (1.01) (36.79) (24.14)
AP^2   -65.66 -59.56*
    (40.94) (27.14)
TDE 0.29*** 0.22 -0.15
  (0.07) (0.15) (0.11)
Regionalism     -1.05
      (0.62)
Presidentialism     0.00
      (0.58)
Party In Government?     -0.66
      (0.57)
Pres. \(\times\) In Government     -0.10
      (0.94)
Party Ideology     -0.02
      (0.01)
Party Legislative Size     8.15***
      (1.36)
Country RI No Yes Yes
Party RI No No Yes
R\(^2\) 0.03 0.13 0.65
N imputations 5 5 5
N 3935 3935 3920
  0.02    
  0.02    
***p < 0.001; **p < 0.01; *p < 0.05

Table 13.1 presents three models of party disunity using retweet-based data from nearly 4,000 legislators. Model 1 is a simple weighted OLS regression including AP and TDE. It shows that AP has a statistically significant and positive effect on disunity, consistent with the idea that greater personalism leads to weaker party cohesion. However, this model explains only 3% of the variation in disunity.

Model 2 adds a country random intercept and includes a quadratic term for AP. Although the coefficient on the quadratic term is not statistically significant, this model improves the fit modestly (R² = 0.13) and accounts for non-independence at the country level.

Model 3, the fully specified model, includes country and party random intercepts as well as controls for institutional and party-level variables. Here, the non-linear relationship between AP and disunity is significant, indicating that disunity increases with AP up to a point, but then declines. This model captures 65% of the variation in disunity.

Among the control variables, only party legislative size is statistically significant: larger parties tend to be more disunited. This supports previous findings that larger legislative delegations are harder to manage cohesively. The results suggest that while AP helps explain disunity, most of the variation occurs within countries and parties, underscoring the need for individual-level and party-level analyses.

Figure 13.7: Predicted Disunity as a Function of AP

Depite the amount of variation in unity we capture, AP is only responsible for a little bit of that explanatory power. What is more, we find that the relationship between unity and AP is not as straightforward as early theories would have us expect. These weaker results, however, are consistent with weak empirical evidence for the relationship between unity and electoral personalism. Figure 13.7 presents predicted values of party disunity (in log scale) as a function of Average Personalism (AP), based on the fully specified Model 3 from Table 13.1. This plot shows how disunity evolves as AP increases, holding all other predictors constant.

# Create a new data frame with varying AP, holding all other predictors constant at typical values
pred_df <- with(
  all_data,
  data.frame(
    AP = seq(min(AP_avg),
      max(AP_avg),
      length.out = 100
    ), # range of AP
    TDE = mean(TDE_avg), # hold TDE constant
    regionalism = mean(regionalism),
    presidentialism = mean(presidentialism),
    democracy = mean(democracy),
    party_ideo = mean(party_ideo, na.rm = TRUE),
    gov_party = 1,
    perseat = 0.23,
    country = "Colombia", # Force new RI samples
    party = "Verde" # ditto
  )
)

# Simulate predicted values and error bands using bootstrapping
sim_pred <- bootMer(mod_list_3[[5]],
  \(x){
    predict(x, newdata = pred_df, re.form = NULL, allow.new.levels = TRUE)
  },
  nsim = 100,
  seed = 831213,
  use.u = TRUE,
  parallel = "multicore",
  ncpus = 10
)
sd_dist <- sd(all_data$Distance)

# Build a data frame with predictions and ±1 standard deviation error bands
pred_dis <- data.frame(
  AP = pred_df$AP,
  fit = (colMeans(sim_pred$t)),
  UB = (apply(sim_pred$t, 2, \(x)mean(x) + sd(x))),
  LB = (apply(sim_pred$t, 2, \(x)mean(x) - sd(x)))
)

# Plot predicted disunity with confidence ribbon
ggplot(pred_dis, aes(AP, fit)) +
  geom_ribbon(aes(ymin = LB, ymax = UB), fill = "gray") + # shaded confidence area
  geom_line(color = "white", linewidth = 1.1) + # fitted line
  ylab("Log Party Disunity") +
  xlab("Average Personalism") +
  theme_bw()

Figure 13.7 illustrates the non-linear relationship between Average Personalism (AP) and log disunity. Based on predictions from the fully specified regression model, disunity increases with AP at low-to-moderate levels, but starts to decline once AP reaches higher values. This pattern challenges standard theoretical expectations, which anticipate a monotonic increase in disunity as electoral incentives for personalism rise. The chapter suggests that while moderate levels of personalism may lead legislators to differentiate themselves and thereby increase disunity, very high levels of personalism may instead foster a different kind of party communication, possibly collaborative in nature. The shaded area around the line represents ±1 standard deviation, capturing the uncertainty in the model’s predictions.

How to read Figure 13.7. Predicted disunity across the range of AP, with a confidence band. This is where the non-linearity from Table 13.1 becomes legible: look for a curve that rises and then falls rather than a straight line.

The substantive reading is that moderate personalism is most corrosive to party unity, while systems at the extreme of personalism appear to develop other forms of cohesion. Check the width of the band at the right-hand end before leaning on that second half — few systems sit at very high AP, so the curve is least well determined exactly where the claim is most surprising.

Figure 13.8: Composite Prediction of Disunity as a Function of Magnitude

How to read Figure 13.8. The same prediction re-expressed in terms of district magnitude rather than AP. Magnitude is an observable rule that a constitutional designer can actually set, whereas AP is a modelled quantity — so this figure translates the finding into the language of institutional design, and is the natural one to read alongside the book’s policy discussion.

While we do see the kind of differential relationship between magnitude and ballot type that early theoretical accounts of personalism would have us expect, it is hard to make out much of a difference in predicted values of unity for the vast majority of simulated values of magnitude. Figure 13.8 presents a composite prediction of party disunity as a function of district magnitude (M) and ballot type, derived by chaining two models. First, it uses the GBM model to predict Average Personalism (AP) from electoral rules (notably M and ballot type), and then feeds these predicted AP values into the regression model from Table 13.1 to predict expected levels of disunity.

# Create a grid of hypothetical electoral systems varying by district magnitude and ballot type
pred_M_data <- expand.grid(
  formula = "dhondt",
  ballot_type = c("closed", "open"), # two ballot types: CLPR and OLPR
  new.nvotes = "One",
  pool_level = "party",
  threshold = 0.05,
  M = 2:150
) # simulate magnitude from 2 to 150


# Predict AP based on electoral rules using the previously trained GBM model
pred_AP_data <- predict(optimalGBMIntra,
  newdata = pred_M_data,
  n.trees = optimalGBMIntra$n.trees
)

# Create full prediction dataset for disunity model using predicted AP values
pred_dist_data <- with(
  all_data,
  data.frame(
    AP = pred_AP_data,
    TDE = mean(TDE_avg),
    M = pred_M_data$M,
    ballot_type = pred_M_data$ballot_type,
    regionalism = mean(regionalism),
    presidentialism = mean(presidentialism),
    democracy = mean(democracy),
    party_ideo = mean(party_ideo, na.rm = TRUE),
    gov_party = median(gov_party, na.rm = TRUE), # 1,
    perseat = mean(perseat, na.rm = TRUE), # 0.51,
    country = "Colombia",
    party = "Verde"
  )
)

# Simulate predicted values and uncertainty using bootstrapping
pred_MAP_sim <- bootMer(mod_list_3[[5]],
  \(x){
    predict(x,
      newdata = pred_dist_data,
      re.form = NULL,
      allow.new.levels = TRUE
    )
  },
  nsim = 100,
  seed = 831213,
  use.u = TRUE,
  parallel = "multicore",
  ncpus = 10
)

# Compile predicted values and ±1 SD intervals
pred_MAP <- data.frame(
  AP = pred_dist_data$AP,
  M = pred_dist_data$M,
  Ballot = c("closed" = "CLPR", "open" = "OLPR")[pred_dist_data$ballot_type],
  fit = (colMeans(pred_MAP_sim$t)),
  UB = (apply(pred_MAP_sim$t, 2, \(x)mean(x) + sd(x))),
  LB = (apply(pred_MAP_sim$t, 2, \(x)mean(x) - sd(x)))
)

# Plot predicted disunity as a function of M and ballot type
ggplot(pred_MAP, aes(M, fit, fill = Ballot, color = Ballot)) +
  geom_smooth(se = FALSE) +
  geom_ribbon(aes(ymin = LB, ymax = UB), alpha = 0.5, color = NA) +
  scale_color_manual(values = c("gray80", "black")) +
  scale_fill_manual(values = c("gray80", "black")) +
  ylab("Log Disunity(AP(M))") +
  xlab("Magnitude") +
  theme_bw()

Figure 13.8 shows how predicted party disunity changes with increasing district magnitude (M) under two types of proportional representation systems: open-list (OLPR) and closed-list (CLPR). The results broadly align with theoretical expectations: in OLPR systems, disunity rises as magnitude increases, reflecting stronger incentives for personalistic behavior and intra-party competition. In CLPR systems, disunity decreases as magnitude increases, likely because party control over candidate ranking makes personal differentiation less relevant.

However, the chapter notes that this classic divergence appears only in moderately large districts (M ≈ 18–50). Outside of that range—particularly in very large districts—the two systems converge, with high magnitude reducing disunity in both cases. This convergence and the wide uncertainty bands suggest that although the theoretical predictions hold in some cases, the real-world effect sizes are small and imprecise for much of the range of M.

Moving Forward

Chapter 13 has provided an in-depth examination of party unity as a critical dimension of intraparty politics. We have shown how electoral system incentives relate to the degree of party cohesion or disunity among legislators. Through the use of retweet network analysis and empirical models, the chapter demonstrated that higher personalism within electoral systems tends to correlate with greater legislative disunity, reflecting the tension between individual politicians’ ambitions and collective party goals. Moreover, the chapter highlighted how district magnitude interacts with intraparty incentives to influence the levels of party cohesion observed in different institutional contexts.

This chapter advances the book’s broader aim of elucidating how electoral rules shape not only the number and ideological positioning of parties but also the internal dynamics within parties themselves. By focusing on party unity, we bridge the understanding of legislative behavior with earlier discussions of electoral incentives, campaigns, and constituency service, further illustrating the two-dimensional Interparty–Intraparty (I–I) space conceptualized at the outset.

Looking ahead, Chapter 14 turns to another manifestation of intraparty incentives by exploring the balance between programmatic policy-making and particularistic pork-barrel politics.

Session Information

Everything above was produced by a single run of this file. The two tables below record the environment that run took place in: the version of R, the machine and operating system, and the version of every package this chapter loads. They are generated while the page is being built, so they always describe the page you are reading rather than some earlier run.

This matters more than it may appear. R packages change: default arguments get revised, estimation routines are rewritten, and a number computed under one version of a modelling package is not guaranteed to reappear under another. Recording the versions is what allows a reader who gets a different answer to tell that apart from using a different version.

The R environment that produced this page
Setting Value
R version R version 4.5.1 (2025-06-13)
Platform aarch64-apple-darwin20
Operating system macOS Tahoe 26.5.2
Collation en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
Time zone America/Chicago
Pandoc 3.10.1
Page built on 30 July 2026
Packages this chapter attaches, with the version used here
Package Version
amen 1.4.5
broom.mixed 0.2.9.7
dplyr 1.1.4
forcats 1.0.0
furrr 0.4.0
future 1.75.0
gbm 2.2.2
ggplot2 3.5.2
ggraph 2.2.2
igraph 2.3.3
intergraph 2.0-4
lme4 1.1-37
lubridate 1.9.4
Matrix 1.7-3
mice 3.19.0
network 1.20.0
progressr 0.18.0
purrr 1.2.2
RColorBrewer 1.1-3
readr 2.1.5
sna 2.8
statnet.common 4.13.0
stringr 1.5.1
texreg 1.39.5
tibble 3.3.0
tidyr 1.3.1
tidyverse 2.0.0

The environment used for the published book

The record for this chapter survives in the originally rendered page: R 4.4.1 (2024-06-14) on macOS Sonoma 14.6.1 (aarch64, darwin20), knitted on 26 August 2024 with pandoc 3.2.1 — an earlier date and an older machine than the other chapters, and the only one to record gbm 2.2.2 rather than 2.1.8.1.

Where a record does survive, it is reproduced in full in RECOVERED-SessionInformation.md, alongside these files.