Pairwise Contests

How Voting Systems Work

Chapter 4 ยท Pairwise Contests

How Voting Systems Work 3 ended with a centre squeeze: Bo, whom most voters preferred to both rivals, was knocked out first because only 32 of 100 ranked Bo first. Both runoff methods looked only at first choices and then at a shrinking field. A different idea is to hold a miniature election for every pair of candidates and look for someone who wins them all. That idea is usually credited to the Marquis de Condorcet, an eighteenth-century French mathematician, and the candidate it describes is called the Condorcet winner. This chapter builds the table of all the pairwise results in Python, shows how the idea can break in a circle, and then looks at two ways of picking a winner anyway: Copeland and Minimax. The maths is counting and comparing two numbers.

What was run, and what to trust
Every Python snippet in this chapter and in the three exercise solutions was executed, and the output in the comments is what Python printed. Facts about the methods come from one web search whose results were mostly an electoral-methods wiki and a few explainer pages; fetched pages are often truncated or model-summarised, so treat the wording as a paraphrase, not a quotation. The sources also disagree in places, and I mark where. The elections, candidates and ballot counts in the code are assumed or illustrative: invented to show a method, not results from any real vote. The simulations rest on simple models that real politics does not follow. Statements marked (check) were not confirmed against a source.

Head-to-Head Contests

Suppose every voter has ranked all the candidates. For any two candidates, say Ana and Bo, you can count how many ballots put Ana above Bo and how many put Bo above Ana. Whoever has more wins that pair. Do this for every pair and you have a pairwise matrix: a grid whose entry in row X and column Y is the number of ballots ranking X above Y. A candidate who wins against every other candidate, by more than half the ballots, is the Condorcet winner. If the ballots are complete and there are no ties, the entry in row X column Y and the entry in row Y column X add up to the number of ballots, so you only ever need to compare one of the two to half the total.

Here is the centre-squeeze profile from the last chapter, with the matrix built from scratch. Each cell is one line of counting, which is the whole method; everything else in this chapter is about what to do with the grid.

from collections import Counter def pairwise(ballots): """wins[(x, y)] = number of ballots that rank x above y.""" names = sorted(ballots[0]) return {(x, y): sum(1 for b in ballots if b.index(x) < b.index(y)) for x in names for y in names if x != y} def condorcet(ballots): """The candidate who beats every rival by more than half the ballots, or None.""" wins = pairwise(ballots) n = len(ballots) for x in sorted(ballots[0]): if all(wins[x, y] > n / 2 for y in ballots[0] if y != x): return x return None # Illustrative numbers: the centre-squeeze profile from the last chapter, invented to show a method. squeeze = ( [("Ana", "Bo", "Cy")] * 35 + [("Cy", "Bo", "Ana")] * 33 + [("Bo", "Ana", "Cy")] * 16 + [("Bo", "Cy", "Ana")] * 16 ) wins = pairwise(squeeze) names = sorted(squeeze[0]) print("row beat column? (ballots ranking row above column)") print(" " + "".join(f"{y:>5}" for y in names)) for x in names: print(f"{x:>5} " + "".join(f"{'-' if x == y else wins[x, y]:>5}" for y in names)) print("first choices:", dict(sorted(Counter(b[0] for b in squeeze).items()))) print("Condorcet winner:", condorcet(squeeze)) # row beat column? (ballots ranking row above column) # Ana Bo Cy # Ana - 35 51 # Bo 65 - 67 # Cy 49 33 - # first choices: {'Ana': 35, 'Bo': 32, 'Cy': 33} # Condorcet winner: Bo

Read row Bo: 65 ballots rank Bo above Ana and 67 rank Bo above Cy, both more than half of 100. Bo wins both pairs and is the Condorcet winner, though Bo came second on first choices. Neither runoff rule found Bo. A rule that always elects the Condorcet winner when one exists is called a Condorcet method (the term appears in the sources I found, which also use “Condorcet-consistent” for the same idea: check the exact wording before relying on either).

When There Is No Winner

Now the awkward part. Majorities between pairs need not line up. Take three equal groups of voters whose rankings are rotations of each other: one group says Ann, then Ben, then Cat; the next says Ben, then Cat, then Ann; the last says Cat, then Ann, then Ben. Every pair has a two-thirds majority in favour of one candidate, and yet the candidates beat one another in a circle.

def pairwise(ballots): """wins[(x, y)] = number of ballots that rank x above y.""" names = sorted(ballots[0]) return {(x, y): sum(1 for b in ballots if b.index(x) < b.index(y)) for x in names for y in names if x != y} def condorcet(ballots): """The candidate who beats every rival by more than half the ballots, or None.""" wins = pairwise(ballots) n = len(ballots) for x in sorted(ballots[0]): if all(wins[x, y] > n / 2 for y in ballots[0] if y != x): return x return None # Illustrative numbers: three equal groups with rotated rankings, invented to show a method. ring = ( [("Ann", "Ben", "Cat")] * 10 + [("Ben", "Cat", "Ann")] * 10 + [("Cat", "Ann", "Ben")] * 10 ) wins = pairwise(ring) for x, y in (("Ann", "Ben"), ("Ben", "Cat"), ("Cat", "Ann")): print(f"{x} vs {y}: {wins[x, y]} to {wins[y, x]}") print("Condorcet winner:", condorcet(ring)) # Ann vs Ben: 20 to 10 # Ben vs Cat: 20 to 10 # Cat vs Ann: 20 to 10 # Condorcet winner: None

Ann beats Ben 20 to 10, Ben beats Cat 20 to 10, and Cat beats Ann 20 to 10. No candidate wins all their contests, so there is no Condorcet winner. This is the Condorcet paradox, also called a Condorcet cycle: each individual voter has a sensible ranking, but the majority taken as a whole does not. The sources I found all describe cycles as the reason a Condorcet method needs a fallback rule for when none exists.

FeaturePlurality or runoff countsPairwise matrix
Looks atfirst choices, then a shrinking fieldevery pair of candidates, using the whole ranking
Always gives a winneryes (apart from ties)no, a cycle can leave nobody undefeated
Finds the centre-squeeze winnerno (How Voting Systems Work 3)yes, if that candidate beats everyone
Work to countone pass over the ballotsone pass per pair: with k candidates, k(k-1)/2 pairs

Two Ways to Break a Cycle

The sources I found list many ways to choose a winner when there is a cycle, including Schulze, Ranked Pairs, Kemeny-Young, Copeland and Minimax. We take the two simplest to code.

Copeland scores each candidate by pairwise wins minus pairwise losses and elects the highest score. The summaries I read say it often ends in a tie when there is a cycle, and that critics object that it counts the number of wins and losses without looking at how big the margins were. Minimax (the sources also call it Simpson or Simpson-Kramer) looks at each candidate’s worst defeat and elects the candidate whose worst defeat is smallest. One page lists several Minimax variants (winning votes, margins, opposition) and says the opposition variant is not a Condorcet method; the pages disagree on whether “Minimax” as a whole is Condorcet-consistent, so the variant matters (conflict between sources: I did not resolve it). The code below uses the winning-votes version, and I have not proved here that it always elects a Condorcet winner when there is one.

The profile below is invented, found by a seeded random search for a case where the two methods disagree. Ana and Cy tie 12 to 12, and everyone else is in a loop of results.

def pairwise(ballots): """wins[(x, y)] = number of ballots that rank x above y.""" names = sorted(ballots[0]) return {(x, y): sum(1 for b in ballots if b.index(x) < b.index(y)) for x in names for y in names if x != y} def condorcet(ballots): """The candidate who beats every rival by more than half the ballots, or None.""" wins = pairwise(ballots) n = len(ballots) for x in sorted(ballots[0]): if all(wins[x, y] > n / 2 for y in ballots[0] if y != x): return x return None def copeland(ballots): """Score = pairwise wins minus pairwise losses (a tied pair counts for neither).""" wins, n = pairwise(ballots), len(ballots) return {x: sum((wins[x, y] > n - wins[x, y]) - (wins[x, y] < n - wins[x, y]) for y in ballots[0] if y != x) for x in sorted(ballots[0])} def minimax(ballots): """Score = size of the candidate's worst defeat, in votes (0 if never beaten). Lower is better. This is the 'winning votes' version; other versions exist.""" wins = pairwise(ballots) return {x: max([wins[y, x] for y in ballots[0] if y != x and wins[y, x] > wins[x, y]], default=0) for x in sorted(ballots[0])} # Illustrative numbers: 24 invented ballots for four candidates, built to give no Condorcet winner. groups = [ (7, ("Di", "Cy", "Ana", "Bo")), (8, ("Ana", "Bo", "Cy", "Di")), (5, ("Bo", "Cy", "Di", "Ana")), (2, ("Bo", "Di", "Ana", "Cy")), (2, ("Ana", "Di", "Bo", "Cy")), ] ballots = [ranking for count, ranking in groups for _ in range(count)] wins = pairwise(ballots) n = len(ballots) for x in sorted(ballots[0]): for y in sorted(ballots[0]): if x < y: print(f"{x} vs {y}: {wins[x, y]} to {n - wins[x, y]}") print("Condorcet winner:", condorcet(ballots)) cope, mini = copeland(ballots), minimax(ballots) print("Copeland scores:", cope) print("Minimax worst defeats:", mini) print("Copeland picks:", max(cope, key=cope.get)) print("Minimax picks:", min(mini, key=mini.get)) # Ana vs Bo: 17 to 7 # Ana vs Cy: 12 to 12 # Ana vs Di: 10 to 14 # Bo vs Cy: 17 to 7 # Bo vs Di: 15 to 9 # Cy vs Di: 13 to 11 # Condorcet winner: None # Copeland scores: {'Ana': 0, 'Bo': 1, 'Cy': 0, 'Di': -1} # Minimax worst defeats: {'Ana': 14, 'Bo': 17, 'Cy': 17, 'Di': 15} # Copeland picks: Bo # Minimax picks: Ana

Ana beats Bo, Bo beats Di, Di beats Ana: a cycle, and Cy ties with Ana. Copeland gives Bo +1 (two wins, one loss), Ana 0, Cy 0 and Di -1, so Bo wins. Minimax asks about worst defeats: Bo’s worst is the 17 to 7 loss to Ana, which is larger than Ana’s worst, a 14 to 10 loss to Di, so Ana wins. Two reasonable pairwise rules, two different winners, from the same twenty-four ballots. The honest summary is that when there is a cycle there is no single correct answer: the rule is a choice about what to value, in this case a count of victories against the size of the worst loss.

How common are cycles?

Cycles sound exotic. Whether they matter depends on how voters are spread. The simulation below uses two assumed models, with 101 voters and 2,000 elections per row. In the “line” model, used in the last two chapters, voters and candidates sit at random points on a line and rank by distance. In the “random ballots” model every ballot is an independent random ranking, which has no political structure at all.

import random def pairwise(ballots): """wins[(x, y)] = number of ballots that rank x above y.""" names = sorted(ballots[0]) return {(x, y): sum(1 for b in ballots if b.index(x) < b.index(y)) for x in names for y in names if x != y} def condorcet(ballots): """The candidate who beats every rival by more than half the ballots, or None.""" wins = pairwise(ballots) n = len(ballots) for x in sorted(ballots[0]): if all(wins[x, y] > n / 2 for y in ballots[0] if y != x): return x return None random.seed(31) trials = 2000 voters = 101 print("model k no Condorcet winner") for model in ("line", "random ballots"): for k in (3, 4, 5): names = [f"C{i}" for i in range(k)] none = 0 for _ in range(trials): if model == "line": pos = {c: random.random() for c in names} ballots = [] for _ in range(voters): v = random.random() ballots.append(tuple(sorted(names, key=lambda c: abs(pos[c] - v)))) else: ballots = [tuple(random.sample(names, k)) for _ in range(voters)] if condorcet(ballots) is None: none += 1 print(f"{model:<15} {k} {none / trials:6.1%}") # model k no Condorcet winner # line 3 0.0% # line 4 0.0% # line 5 0.0% # random ballots 3 8.5% # random ballots 4 17.7% # random ballots 5 25.9%

On the line there are no cycles in these runs. That fits the earlier claim that a Condorcet winner exists with an odd number of voters on a line (I did not look up a proof). With random ballots cycles grow quickly with the field: 8.5%, 17.7% and 25.9% for three, four and five candidates. Neither model is real. The first has too little disagreement and the second too much, so real cycles are somewhere in between, and how often is a question for data, not for this simulation. A commonly quoted limit of about 8.8% for three candidates under random ballots is from memory (check).

Common mistakes
Comparing only the cell for X over Y and forgetting the mirror cell (they must add to the number of ballots when there are no ties); calling the candidate with the most pairwise wins a “Condorcet winner” when they have lost a contest; reading a Copeland tie as a bug; saying “Minimax” without saying which version; and treating the cycle rates of an assumed random-ballot model as the cycle rate in a real election.
Exercise 1

Twenty-five members rank five candidates (assumed numbers). Build and print the pairwise matrix, say whether a Condorcet winner exists, and compare that winner with the candidate who has the most first choices.

๐Ÿ“„ View solution
Exercise 2

Three groups of 12, 9 and 6 voters have rotated rankings of Ann, Ben and Cat (assumed numbers). Print every pairwise result, then compute the Copeland scores and the Minimax worst defeats. What does each method do with the cycle?

๐Ÿ“„ View solution
Exercise 3

Generate 5,000 seeded random profiles of four candidates (five groups of two to eight voters each). Count how many have no Condorcet winner and, among those, how many give a single Copeland winner that differs from the single Minimax winner.

๐Ÿ“„ View solution

Quick Reference

  • Pairwise matrix: entry (X, Y) = number of ballots ranking X above Y
  • Condorcet winner: beats every other candidate head to head by more than half the ballots
  • Condorcet cycle: A beats B, B beats C, C beats A, so there may be no Condorcet winner
  • Copeland: score = pairwise wins minus losses; highest score wins; ties are common in cycles
  • Minimax: smallest worst defeat wins; say which version (winning votes, margins, opposition)
  • Python habit: build the pairwise dictionary once, then compute every method from it
  • Always state: how pairwise ties are scored, and which variant of a method you used
Coming next
How Voting Systems Work 5 is about points and scores: the Borda count, approval and score voting, and how scoring rules reward different things.