Design an Election

How Voting Systems Work

Chapter 10 · Design an Election

This is the capstone. Nine chapters have given you a shelf of tools: plurality (How Voting Systems Work 2), runoffs and instant-runoff (How Voting Systems Work 3), pairwise methods (How Voting Systems Work 4), points and scores (How Voting Systems Work 5), the impossibility results and strategy (How Voting Systems Work 6). Here you use them together on a practical problem: an invented club of 25 members must pick one of five options, and you have to choose the rule. Instead of arguing from taste, you build a small laboratory in Python, run thousands of simulated elections, and let the numbers show the trade-offs. Seats, districts and weighted votes (How Voting Systems Work 7, 8 and 9) do not appear here, because a single-winner club vote needs none of them.

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. The two background facts (the median voter result and the Gibbard–Satterthwaite theorem) come from two web searches whose results were encyclopedia mirrors, lecture notes and arXiv papers; I did not read the original papers, so treat the wording as a paraphrase. Everything else is a model I invented: the club, the 2-D “issue space”, how voters turn distances into ballots, the polarised electorate, and every percentage in the tables. Those percentages describe this model with these settings, not real elections. Statements marked (check) were not confirmed against a source.

The Problem and the Yardsticks

Suppose a club of 25 members must choose among 5 candidate options (a venue, a chairperson, a project; the details do not matter). Before comparing rules you must say what “good” means, because How Voting Systems Work 6 showed that no rule satisfies every fairness idea at once. This chapter uses three yardsticks, each easy to compute:

  • Agreement with the Condorcet winner (the option that beats every other head to head, How Voting Systems Work 4). A rule that skips it needs a good reason.
  • Regret: the average distance from the members to the winner, minus the average distance to the best possible option. Zero means the winner is the closest option to the club as a whole.
  • Resistance to burying: if a bloc of members dishonestly ranks the winner last, can they get an option they truly prefer?

To simulate people you need a model of preferences. The one below places every voter and every candidate at a random point in a unit square (an “issue space” with two dimensions), and a voter prefers candidates that are closer. On a single line, the sources I found credit Duncan Black (1948) with the median voter result: when preferences are single-peaked, the median voter’s favourite beats every rival head to head, so it is a Condorcet winner. The same sources say this does not carry over cleanly to more dimensions, and that the McKelvey–Schofield chaos theorem shows there may be no Condorcet winner at all. Two dimensions are therefore a harder, more realistic test, and the simulation will report how often a Condorcet winner exists.

The Toolkit

Save this as votelab.py; the later snippets and the exercise solutions import from it. Honest ballots come in three forms from the same voter: a full ranking, an approval set and a 0 to 10 score list. Two choices here are my own assumptions: a voter approves the candidates closer than their average, and scores stretch from 10 for the closest candidate to 0 for the furthest. Ties always go to the lowest-numbered candidate, which is arbitrary but keeps runs repeatable. The Copeland rule is simplified: a head-to-head win counts one, a tie counts nothing.

import random from math import dist as distance def make_electorate(rng, kind="uniform", n_voters=25, n_cands=5): """Return (voters, candidates): points in a 2-D 'issue space' from 0 to 1.""" cands = [(rng.random(), rng.random()) for _ in range(n_cands)] if kind == "uniform": voters = [(rng.random(), rng.random()) for _ in range(n_voters)] else: # "polarised": two clusters of voters around (0.25, 0.5) and (0.75, 0.5) voters = [] for _ in range(n_voters): cx = rng.choice([0.25, 0.75]) voters.append((rng.gauss(cx, 0.1), rng.gauss(0.5, 0.15))) return voters, cands def make_ballots(voters, cands): """Honest ballots: a ranking, an approval set and a 0-10 score list per voter.""" rank, approve, score = [], [], [] for v in voters: d = [distance(v, c) for c in cands] rank.append(sorted(range(len(cands)), key=lambda c: (d[c], c))) mean = sum(d) / len(d) approve.append({c for c in range(len(cands)) if d[c] < mean}) # assumption: approve the closer-than-average lo, hi = min(d), max(d) score.append([round(10 * (hi - x) / (hi - lo)) for x in d]) # assumption: best gets 10, worst gets 0 return {"rank": rank, "approve": approve, "score": score, "n": len(cands)} def top(totals): """Index of the largest total; ties go to the lowest index (an arbitrary, stated rule).""" return max(range(len(totals)), key=lambda c: (totals[c], -c)) def plurality(b): t = [0] * b["n"] for r in b["rank"]: t[r[0]] += 1 return top(t) def irv(b): alive = set(range(b["n"])) while True: t = {c: 0 for c in alive} for r in b["rank"]: t[next(c for c in r if c in alive)] += 1 if 2 * max(t.values()) > len(b["rank"]) or len(alive) == 1: return max(sorted(t), key=lambda c: t[c]) alive.remove(min(sorted(t, reverse=True), key=lambda c: t[c])) # fewest first votes; ties drop the highest index def borda(b): t = [0] * b["n"] for r in b["rank"]: for place, c in enumerate(r): t[c] += b["n"] - 1 - place return top(t) def pairwise_wins(b): n, wins = b["n"], [0] * b["n"] for a in range(n): for c in range(a + 1, n): a_over_c = sum(1 for r in b["rank"] if r.index(a) < r.index(c)) c_over_a = len(b["rank"]) - a_over_c if a_over_c > c_over_a: wins[a] += 1 elif c_over_a > a_over_c: wins[c] += 1 return wins def condorcet(b): w = pairwise_wins(b) return w.index(b["n"] - 1) if (b["n"] - 1) in w else None def copeland(b): return top(pairwise_wins(b)) # simplified Copeland: a win counts 1, a tie counts 0 def approval(b): t = [0] * b["n"] for s in b["approve"]: for c in s: t[c] += 1 return top(t) def score(b): return top([sum(s[c] for s in b["score"]) for c in range(b["n"])]) METHODS = {"plurality": plurality, "irv": irv, "borda": borda, "copeland": copeland, "approval": approval, "score": score} def regret(voters, cands, winner): """Mean distance from voters to the winner, minus the best any candidate achieves.""" means = [sum(distance(v, c) for v in voters) / len(voters) for c in cands] return means[winner] - min(means)

One Election, Six Answers

Start with a single seeded election of 25 voters and 4 candidates (A to D). The seed was chosen after searching a few dozen seeds for one in which the rules disagree, so this is a deliberately dramatic example, not a typical one; the next section measures how typical it is.

import random from votelab import make_electorate, make_ballots, condorcet, METHODS, regret rng = random.Random(11) voters, cands = make_electorate(rng, "uniform", n_voters=25, n_cands=4) b = make_ballots(voters, cands) names = "ABCD" firsts = [sum(1 for r in b["rank"] if r[0] == c) for c in range(4)] print("first places:", dict(zip(names, firsts))) cw = condorcet(b) print("Condorcet winner:", names[cw] if cw is not None else "none (a cycle or ties)") for name, method in METHODS.items(): w = method(b) print(f"{name:>9} -> {names[w]} regret {regret(voters, cands, w):.3f}") # first places: {'A': 2, 'B': 9, 'C': 5, 'D': 9} # Condorcet winner: C # plurality -> B regret 0.112 # irv -> D regret 0.083 # borda -> C regret 0.000 # copeland -> C regret 0.000 # approval -> A regret 0.004 # score -> A regret 0.004

Four different winners from the same 25 ballots. Plurality picks B, which ties D on 9 first places (the lowest-index tie rule decides) yet is the worst choice here by regret. Instant-runoff picks D. Borda and Copeland pick C, the Condorcet winner. Approval and score pick A, who has only 2 first places but is acceptable to many voters, so a method that counts breadth rather than first places favours A. Each answer is defensible under some definition of the word “winner”, and that is the course in one output.

Thousands of Elections

One dramatic election proves little, so repeat it. The next snippet runs 2000 seeded elections of 25 voters and 5 candidates for two kinds of electorate, uniform and polarised (voters in two clusters), and tallies how often each rule picks the Condorcet winner and how much regret it leaves on average.

import random from votelab import make_electorate, make_ballots, condorcet, METHODS, regret def trial(kind, runs=2000, seed=31): rng = random.Random(seed) has_cw = 0 agree = {m: 0 for m in METHODS} total_regret = {m: 0.0 for m in METHODS} for _ in range(runs): voters, cands = make_electorate(rng, kind) b = make_ballots(voters, cands) cw = condorcet(b) if cw is not None: has_cw += 1 for name, method in METHODS.items(): w = method(b) total_regret[name] += regret(voters, cands, w) if cw is not None and w == cw: agree[name] += 1 return runs, has_cw, agree, total_regret for kind in ("uniform", "polarised"): runs, has_cw, agree, total_regret = trial(kind) print(f"{kind}: {runs} elections, Condorcet winner exists in {has_cw / runs:.1%}") print(" method picks Condorcet winner mean regret") for name in METHODS: print(f"{name:>9} {agree[name] / has_cw:>20.1%} {total_regret[name] / runs:>13.4f}") print() # uniform: 2000 elections, Condorcet winner exists in 96.7% # method picks Condorcet winner mean regret # plurality 54.4% 0.0289 # irv 77.2% 0.0111 # borda 84.9% 0.0041 # copeland 100.0% 0.0028 # approval 75.4% 0.0059 # score 84.2% 0.0019 # # polarised: 2000 elections, Condorcet winner exists in 96.9% # method picks Condorcet winner mean regret # plurality 68.0% 0.0242 # irv 84.4% 0.0116 # borda 81.4% 0.0053 # copeland 100.0% 0.0059 # approval 72.3% 0.0089 # score 81.8% 0.0024

Read the table with care. Copeland scores 100% on agreement because it is built to choose the Condorcet winner when one exists, so that column cannot tell Copeland apart from its own definition. A Condorcet winner existed in about 97% of these elections, so cycles are rare in this model (real clubs, with strong factions, may differ; I did not test that). Plurality is clearly the weakest on both measures. Instant-runoff beats plurality on both, and is close to approval on agreement but behind Borda, Score and Copeland. On mean regret the score method is lowest in both electorates, but it benefits from my assumption that voters report stretched, honest scores, which hands it more information than a ranking carries. Polarisation changed the order a little: for example, plurality’s agreement rose from 54.4% to 68.0%, and Borda’s fell from 84.9% to 81.4%.

MethodBallot asked forAgrees with Condorcet winner (uniform)Mean regret (uniform)
Pluralityone name54.4%0.0289
Instant-runofffull ranking77.2%0.0111
Bordafull ranking84.9%0.0041
Copelandfull ranking100.0% (by definition)0.0028
Approvala set of names75.4%0.0059
Scorea number per name84.2%0.0019

Can the Ballots Be Gamed?

The Gibbard–Satterthwaite theorem, proved independently by Allan Gibbard (1973) and Mark Satterthwaite (1975) according to the sources I found, says that with three or more candidates any deterministic rule in which every candidate can win and which is never manipulable must be a dictatorship. In plain terms, every sensible rule can be gamed somewhere; the useful question is how easily. This test is one narrow kind of gaming. A bloc is formed from the voters whose favourite is the strongest non-winning candidate, and every one of them buries the current winner: last in the ranking, unapproved, scored 0. The bloc succeeds if the new winner is closer to them, on average, than the old one by their true positions.

import random from math import dist as distance from votelab import make_electorate, make_ballots, METHODS def bury(b, voter, c): """Return a copy of the ballots in which one voter puts candidate c last, unapproved, scored 0.""" new = {"n": b["n"], "rank": list(b["rank"]), "approve": list(b["approve"]), "score": list(b["score"])} new["rank"][voter] = [x for x in b["rank"][voter] if x != c] + [c] new["approve"][voter] = b["approve"][voter] - {c} new["score"][voter] = [0 if x == c else s for x, s in enumerate(b["score"][voter])] return new def success_rate(method, kind, runs=1000, seed=7): rng = random.Random(seed) tried = worked = 0 for _ in range(runs): voters, cands = make_electorate(rng, kind) b = make_ballots(voters, cands) winner = method(b) # the bloc: voters whose favourite is the non-winner with most first places firsts = [sum(1 for r in b["rank"] if r[0] == c) for c in range(b["n"])] rival = max((c for c in range(b["n"]) if c != winner), key=lambda c: (firsts[c], -c)) bloc = [i for i, r in enumerate(b["rank"]) if r[0] == rival] if not bloc: continue tried += 1 fake = b for i in bloc: fake = bury(fake, i, winner) new = method(fake) mean = lambda c: sum(distance(voters[i], cands[c]) for i in bloc) / len(bloc) if new != winner and mean(new) < mean(winner): worked += 1 # the bloc really is better off by its true positions return worked, tried for name, method in METHODS.items(): worked, tried = success_rate(method, "uniform") print(f"{name:>9}: burying helped the bloc in {worked:>3} of {tried} elections ({worked / tried:.1%})") # plurality: burying helped the bloc in 0 of 1000 elections (0.0%) # irv: burying helped the bloc in 31 of 1000 elections (3.1%) # borda: burying helped the bloc in 437 of 1000 elections (43.7%) # copeland: burying helped the bloc in 280 of 1000 elections (28.0%) # approval: burying helped the bloc in 461 of 1000 elections (46.1%) # score: burying helped the bloc in 489 of 1000 elections (48.9%)

Plurality scores 0.0% because burying someone who is not your first choice cannot change a first-place count; it is not that plurality resists gaming, only that this manoeuvre is useless against it. Plurality’s weakness is of another kind, the compromise voting of How Voting Systems Work 2, which this test does not try. Within what it does measure, instant-runoff is the hardest to bury (3.1%), while Borda, approval and score are open to it in 43.7%, 46.1% and 48.9% of elections. In Copeland the figure is 28.0%. A single narrow strategy tells you little about the others, so read these as signs of where to look, not a final verdict on manipulation.

Choosing for the Club

The simulation cannot name a winner for you, because the yardsticks pull in different directions. No rule leads on all three. Weights make the decision, and the weights are a statement about what your club values.

If the club cares most about…Lean towardsWhy, from this modelWatch out for
Never choosing a loser to a rival head to headCopeland or another Condorcet methodpicks the Condorcet winner whenever one exists; low regretneeds full rankings; a cycle needs a pre-agreed tie rule
Lowest average dissatisfactionScore votinglowest mean regret in both electoratesrelies on honest, comparable scores; easy to exaggerate
Hard to game by buryingInstant-runoff or pluralityburying helped the bloc in 3.1% and 0.0% of electionsweaker on the other two yardsticks; plurality has compromise voting
Simplest to explain and countPlurality or approvalone mark per name; no ranking arithmeticplurality had the most regret here

A practical suggestion of mine, not a result from the simulation: for a 25-member club that is comfortable ranking its options, collect ranked ballots, compute the Condorcet winner, and write down in advance what happens if there is none (the Copeland rule, say). Rankings are cheap to collect, they let you check several rules on the same ballots, and the rule is agreed before anyone knows the candidates, which removes the temptation to pick the rule that favours a particular option. Exercise 3 lets you change the weights and watch the recommendation move.

Common mistakes
Treating a simulation as evidence about real elections when it only shows what the model implies; reading a method’s 100% score when the yardstick is its own definition; picking a rule after seeing the candidates, so that the rule is chosen to produce a result; comparing methods on a single dramatic election instead of thousands; giving score and approval ballots more information than the ranking ballots and then crediting the method; and forgetting that one strategy test (burying) covers only one kind of manipulation. Also remember the lowest-index tie rule is arbitrary: with only 25 voters, ties are common enough that a real club must say how it breaks them.
Exercise 1

Add a top-two runoff (the two largest first-place totals meet in a second round) to the toolkit. On 2000 uniform elections, compare plurality, the runoff and instant-runoff on agreement with the Condorcet winner and mean regret.

📄 View solution
Exercise 2

A club may care most about never electing someone a large part of the membership dislikes intensely. For 2000 polarised elections, count how often each method’s winner is ranked last by at least a quarter of the voters.

📄 View solution
Exercise 3

Build a weighted scorecard from the three yardsticks (Condorcet agreement, low regret, burying resistance) and rank the six methods under three sets of invented weights: fairness first, closeness first, honesty first. Does the best method change with the weights?

📄 View solution

Quick Reference

  • Choose the yardsticks first: Condorcet agreement, regret, resistance to gaming, simplicity; none wins on all
  • Spatial model: voters and candidates as points; closer means preferred (an assumption)
  • Regret: mean distance to the winner minus mean distance to the best candidate
  • Burying test: a bloc ranks the winner last; success means a truly better result for the bloc
  • Gibbard–Satterthwaite: with three or more candidates, every non-dictatorial rule in which anyone can win is open to manipulation somewhere
  • Always state: the ballot model, the tie rule, the number of elections and the seed behind any percentage
  • Settle the rule, including tie-breaks, before the candidates are known
Where to go from here
You now have a working toolkit and the habit of asking what a number measures. To go further, change the model: add voters who do not turn out, candidates who enter late, or more issue dimensions, and see which conclusions survive. Re-read How Voting Systems Work 6 with the toolkit open and test another fairness criterion by code. Try the seat and district chapters on real published results (How Voting Systems Work 7 and 8), and use the power indices from How Voting Systems Work 9 on a real council. Whatever you choose, keep the three rules of this course: run it, label your assumptions, and distrust any single number.