Can Primary Elections Cause Polarization? A Toy Model

Dave

Introduction

There has been much speculation that Americans have become more politically polarized in recent years. But the evidence seems to point to political parties become more polarized rather than the population itself. In other words, there is more ideoleogical sorting in party membership than before. Andrew Gelman recently blogged about this (citing a post by Claude Fischer), and I agree with his observation that it seems like people tend to cast votes more against the other side than for their own these days.

Allen Downey looked at survey results a couple of years ago and came to a similar conclusion. He also points out that "the percentage of Nonpartisans has increased to the point where they are now the plurality."

In this post, I will present a toy model that extends the median voter theorem to show how primary presidential elections might lead to these results. To be clear, I will prove nothing here. I won't even try to fit a model to empirical data. This is more in the spirit of Schelling's segregation model, which showed how slight in-group preferences could lead to highly segregated housing. He never claimed this was the only cause or even the primary cause of observed, real-life segregation; merely that it was a plausible mechanism through which housing segregation could occur. Similarly, I'll just try to give an intuition of how primary elections might lead to political polarization and growing independents under some assumptions.

The median voter theorem and Hotelling's law

The median voter theorem supposes that voters have preferences along a one-dimensional spectrum and that voters cast votes for the candidate closest to their position, and then states voters will elect the candidate whose position is closest to the median voter. I'm also supposing Hotelling's law behavior, whereby politicians will position themselves at the median to win elections. I'm simplifying a bit, but that's a good enough intuition in a "one person, one vote" system.

An illustration of the median voter theorem.
Attribution: Colin.champion, CC BY-SA 4.0, via Wikimedia Commons

Extension and repetition

In this post, I will suppose the following:

  • Political positions of the electorate are well described by a Gaussian distribution
  • The winning candidate of party's primary positions his or herself at the median vote of that party
  • Voters use presidential nominees as flagpoles of each party's position
  • Voters estimate the midpoint between flagpoles of each party, and this midpoint become the flagpole for independents
  • Voters then categorize themselves according to the flagpole that is closest to them

We will develop a toy model for this, and see how it leads to growing polarization between parties and a growing number of independents over time.

import numpy as np
import matplotlib.pyplot as plt

# generate first voters, who each belong to a party to start
np.random.seed(3)
party1 = np.random.normal(0, 1, size=500)
party2 = np.random.normal(0, 1, size=500)

all_voters = np.hstack([party1, party2])

party1_flagpole = np.median(party1)
party2_flagpole = np.median(party2)
independent_flagpole = (party1_flagpole + party2_flagpole)/2

party_array = np.array([independent_flagpole, party1_flagpole, party2_flagpole])
rounds = 7
results = np.empty((rounds, len(all_voters)))
flagpoles = np.empty((rounds, 3))
for i in range(rounds):
    flagpoles[i, :] = party_array
    closest_indices = np.abs(all_voters[:, np.newaxis] - party_array).argmin(axis=1)
    results[i, :] = closest_indices
    # now group into new parties
    party1 = all_voters[closest_indices==1]
    party2 = all_voters[closest_indices==2]

    party1_flagpole = np.median(party1)
    party2_flagpole = np.median(party2)
    independent_flagpole = (party1_flagpole + party2_flagpole)/2
    party_array = np.array([independent_flagpole, party1_flagpole, party2_flagpole])
    

We can look at party membership over time

plt.plot((results==0).sum(axis=1), label='Independents')
plt.plot((results==1).sum(axis=1), label='Party 1')
plt.plot((results==2).sum(axis=1), label='Party 2')
plt.xticks(ticks=range(results.shape[0]), labels=range(1, results.shape[0] + 1))
plt.legend()
plt.gca().set_ylabel('Voter Counts')
plt.gca().set_xlabel('Election Number')
plt.show();

png

And look at the flagpole positions over time.

plt.plot(flagpoles)
plt.gca().set_ylabel('Flagpole position')
plt.gca().set_xlabel('Election Number')
plt.xticks(ticks=range(results.shape[0]), labels=range(1, results.shape[0] + 1)) 
plt.show();

png

Let's look at how it evolves over time with voters and their relative positions.

party_colors = ['C0', 'C1', 'C2']
party_names = ['Independents', 'Party 1', 'Party 2']
for round_idx in range(rounds):
    plt.figure(figsize=(8, 2))

    for party in range(3):
        party_voters = all_voters[results[round_idx] == party]
        plt.scatter(
            party_voters,
            np.zeros_like(party_voters),
            color=party_colors[party],
            marker='|',
            s=100,
            label=party_names[party],
            alpha=0.2,
        )

    for party in range(3):
        flagpole_pos = flagpoles[round_idx, party]
        plt.plot(
            [flagpole_pos, flagpole_pos],
            [0, 0.2],
            color=party_colors[party],
            linestyle='-',
            linewidth=2,
            label="_nolegend_",
        )

    plt.title(f'Round {round_idx + 1}')
    plt.gca().set_xlabel('Political Position')
    plt.yticks([])
    plt.legend()

    plt.show()

png

png

png

png

png

png

png

Now instead of showing it by political position on the x-axis, let's give each voter equal width on the x-axis to better show how the proportions change. We'll align the flagpoles with the closest voter.

for round_idx in range(rounds):
    plt.figure(figsize=(8, 2))

    # voter positions and the corresponding party votes for this round
    voters_positions = all_voters
    parties = results[round_idx] 

    # sort voters and their corresponding party by their position
    sorted_indices = np.argsort(voters_positions)
    sorted_voters = voters_positions[sorted_indices]
    sorted_parties = parties[sorted_indices].astype(int)

    # generate evenly spaced x-values for the voters, from 0 to 1
    x_values = np.linspace(0, 1, len(sorted_voters))

    
    for i in range(len(sorted_voters) - 1):
        plt.plot(
            x_values[i:i + 2], [0, 0],
            color=party_colors[sorted_parties[i]],
            lw=2, alpha=0.2
        )

    # flagpoles
    for party in range(3):
        flagpole_pos = flagpoles[round_idx, party]
        nearest_voter_idx = np.abs(sorted_voters - flagpole_pos).argmin()
        nearest_voter_x = x_values[nearest_voter_idx]

        plt.axvline(
            x=nearest_voter_x,
            color=party_colors[party],
            linestyle='--',
            linewidth=2,
            label="_nolegend_" 
        )

    for party in range(3):
        plt.plot([], [], color=party_colors[party], lw=2, label=party_names[party])

    plt.title(f'Round {round_idx + 1}')
    plt.gca().set_xlabel('Voter Rank (Equal Spacing)')
    plt.yticks([])  # Remove y-axis

    plt.legend(loc='upper right')

    plt.show()

png

png

png

png

png

png

png

So that illustrates the intuition behind this toy model, how it could lead to political polarization between parties and growth in independents. However, one might notice that most of the polarization happened quite quickly toward the beginning (this is in part because I started independents as such a small group). So next, let's look at a probabilistic version of this model, which can give us a more gradual adjustment.

Probabilistic model

In this model, we'll suppose that the probability of a voter's party membership is inversely proportional to the squared distance their distance to the flagpoles. This moves away from the median voter theorem, but still retains the essence of voters judging their distance on political positions.

# regenerate the initial voters
np.random.seed(3)
party1 = np.random.normal(0, 1, size=500)
party2 = np.random.normal(0, 1, size=500)

all_voters = np.hstack([party1, party2])

party1_flagpole = np.median(party1)
party2_flagpole = np.median(party2)
independent_flagpole = (party1_flagpole + party2_flagpole)/2

party_array = np.array([independent_flagpole, party1_flagpole, party2_flagpole])
rounds = 12
results = np.empty((rounds, len(all_voters)))
flagpoles = np.empty((rounds, 3))
for i in range(rounds):
    flagpoles[i, :] = party_array

    # distance of each voter to each flagpole
    distances = np.abs(all_voters[:, np.newaxis] - party_array)

    # inverse squared distances
    inverse_distances = 1 / (distances**2)
    inverse_distances[np.isinf(inverse_distances)] = 1_000_000

    probabilities = inverse_distances / inverse_distances.sum(axis=1, keepdims=True)

    # randomly assign each voter to a party based on the calculated probabilities
    results[i, :] = [np.random.choice([0, 1, 2], p=prob) for prob in probabilities]

    # group into new parties based on their affiliation
    party1 = all_voters[results[i, :] == 1]
    party2 = all_voters[results[i, :] == 2]

    # recalculate flagpoles as the median of the new party groups
    party1_flagpole = np.median(party1)
    party2_flagpole = np.median(party2)
    independent_flagpole = (party1_flagpole + party2_flagpole) / 2

    # update party_array for the next round
    party_array = np.array([independent_flagpole, party1_flagpole, party2_flagpole])

We can see this leads to more gradual (and noisier) change over time.

plt.plot((results==0).sum(axis=1), label='Independents')
plt.plot((results==1).sum(axis=1), label='Party 1')
plt.plot((results==2).sum(axis=1), label='Party 2')
plt.xticks(ticks=range(results.shape[0]), labels=range(1, results.shape[0] + 1))
plt.legend()
plt.gca().set_ylabel('Voter Counts')
plt.gca().set_xlabel('Election Number')
plt.show();

png

And we can see how the flagpoles change over time as well.

plt.plot(flagpoles)
plt.xticks(ticks=range(results.shape[0]), labels=range(1, results.shape[0] + 1))
plt.gca().set_ylabel('Flagpole position')
plt.gca().set_xlabel('Election Number')
plt.show();

png

Let's visualize it over time with respect to voter's positions.

for round_idx in range(rounds):
    plt.figure(figsize=(8, 2))

    for party in range(3):
        party_voters = all_voters[results[round_idx] == party]
        plt.scatter(
            party_voters,
            np.zeros_like(party_voters),
            color=party_colors[party],
            marker='|',
            s=100,
            label=party_names[party],
            alpha=0.2,
        )

    for party in range(3):
        flagpole_pos = flagpoles[round_idx, party]
        plt.plot(
            [flagpole_pos, flagpole_pos],
            [0, 0.2],
            color=party_colors[party],
            linestyle='-',
            linewidth=2,
            label="_nolegend_",
        )

    plt.title(f'Round {round_idx + 1}')
    plt.gca().set_xlabel('Political Position')
    plt.yticks([])
    plt.legend()

    plt.show()

png

png

png

png

png

png

png

png

png

png

png

png

And let's look at it again in terms of rank ordering voters and giving each equal width on the x-axis.

for round_idx in range(rounds):
    plt.figure(figsize=(8, 2))

    # voter positions and the corresponding party votes for this round
    voters_positions = all_voters
    parties = results[round_idx] 

    # sort voters and their corresponding party by their position
    sorted_indices = np.argsort(voters_positions)
    sorted_voters = voters_positions[sorted_indices]
    sorted_parties = parties[sorted_indices].astype(int)

    # generate evenly spaced x-values for the voters, from 0 to 1
    x_values = np.linspace(0, 1, len(sorted_voters))

    
    for i in range(len(sorted_voters) - 1):
        plt.plot(
            x_values[i:i + 2], [0, 0],
            color=party_colors[sorted_parties[i]],
            lw=2, alpha=0.2
        )

    # flagpoles
    for party in range(3):
        flagpole_pos = flagpoles[round_idx, party]
        nearest_voter_idx = np.abs(sorted_voters - flagpole_pos).argmin()
        nearest_voter_x = x_values[nearest_voter_idx]

        plt.axvline(
            x=nearest_voter_x,
            color=party_colors[party],
            linestyle='--',
            linewidth=2,
            label="_nolegend_" 
        )

    for party in range(3):
        plt.plot([], [], color=party_colors[party], lw=2, label=party_names[party])

    plt.title(f'Round {round_idx + 1}')
    plt.gca().set_xlabel('Voter Rank (Equal Spacing)')
    plt.yticks([])  # Remove y-axis

    plt.legend(loc='upper right')

    plt.show()

png

png

png

png

png

png

png

png

png

png

png

png

We see here that we lose the perfect sorting of voters along our single dimension, but it's not too far off, and we get a more gradual move toward the polarization. One could imagine including a softmax or adding flagpoles at the extremes to cut down on improbable cross-party affiliation. Another reason polarization may occur more slowly in practice could be that primary voters are voting for candidates that strike a balance between appealing to them and can appeal to independents in the general election.

Conclusion

So, what have we proved? Nothing. As mentioned up top, this is just a toy model and there are other plausible causes of party polarization. But I encourage you to go look at Allen Downey's post on the topic, where we can see that this move toward modern polarization took place shortly after national binding primary era started in 1972. In the absence of primaries, Hotelling's law suggests political parties would be more focused on nominating candidates positioned toward the median of the general electorate (or at least of the swing states), rather than the median of their own party. Hopefully this post gives some intuition as to how such polarization might arise under a few simplifying assumptions about party primaries and affiliation.