German 2017 federal legislative (Bundestag) elections

A hard-core example. Germany’s federal legislative elections feature a mixed-member proportional (MMP) system where some candidates are elected in single-member constituencies while more are added through proportional allocation of party votes; additional seats are awarded to maintain the proportionality of the result.

This example was constructed according to the procedure and results laid out in the official document by the German Federal Returning Officer.

[1]:
import sys
import os
import csv
from decimal import Decimal

sys.path.append(os.path.join('..', '..'))
import votelib.convert
import votelib.candidate
import votelib.evaluate.core
import votelib.evaluate.threshold
import votelib.evaluate.proportional

Vote loading

Each voter casts two votes in German legislative election; the first one goes to a specific person standing in the local constituency, and the second one goes to a party. Independent constituency candidates are a minority and none has won a seat in the past fifty years; therefore, we omit this step and only consider votes for parties.

Since the voting system has two nesting levels - federal states (Land) and local constituency (Wahlkreis) -, we construct two doubly-nested dictionaries - one for the first votes (Erststimme), and one for the second votes (Zweitstimme).

[2]:
fpath = os.path.join('..', '..', 'tests', 'real', 'data', 'de_bdt_2017.csv')
with open(fpath, encoding='utf8') as infile:
    rows = list(csv.reader(infile, delimiter=';'))
party_names = [item for item in rows[0][2:] if item]
erst_stimmen, zweit_stimmen = {}, {}
for row in rows[2:]:
    wahlkreis, land = row[:2]
    # Next cells in rows are first and second votes alternating, grouped by party. Empty cells are zeroes.
    row[2:] = [int(x) if x else 0 for x in row[2:]]
    # Add the first and second votes to the register.
    erst_stimmen.setdefault(land, {})[wahlkreis] = dict(zip(party_names, row[2::2]))
    zweit_stimmen.setdefault(land, {})[wahlkreis] = dict(zip(party_names, row[3::2]))
print(erst_stimmen['Berlin']['Berlin-Mitte'])
{'CDU': 27654, 'SPD': 35036, 'DIE LINKE': 30492, 'GRÜNE': 26781, 'CSU': 0, 'FDP': 9017, 'AfD': 11782, 'PIRATEN': 0, 'NPD': 0, 'FREIE WÄHLER': 648}

Evaluator construction

We will need a complicated evaluator for the election.

The constituency winners are determined by simple plurality, each constituency giving a single winner. We will wrap this into a ByConstituency object to evaluate each constituency, and a combination of SelectionToDistribution and MergedDistributions to obtain party results at federal state level for each federal state:

[3]:
round1_eval = votelib.evaluate.core.ByConstituency(          # for each federal state
    votelib.evaluate.core.PostConverted(                     # sum all constituency results
        votelib.evaluate.core.ByConstituency(                # evaluated over each constituency
            votelib.evaluate.core.PostConverted(
                votelib.evaluate.core.Plurality(),           # by plurality selection
                votelib.convert.SelectionToDistribution()    # converted to a distribution format {winner: 1}
            ),
            apportioner=1,                                   # each constituency has a single winner
        ),
        votelib.convert.MergedDistributions()
    )
)

Before the elections, the shares of the nationwide total of 598 seats allocated to each federal state are determined according to their census population by the Sainte-Laguë (Schepers) method:

[4]:
land_inhab = {
    'Schleswig-Holstein': 2673803,
    'Hamburg': 1525090,
    'Niedersachsen': 7278789,
    'Bremen': 568510,
    'Nordrhein-Westfalen': 15707569,
    'Hessen': 5281198,
    'Rheinland-Pfalz': 3661245,
    'Baden-Württemberg': 9365001,
    'Bayern': 11362245,
    'Saarland': 899748,
    'Berlin': 2975745,
    'Brandenburg': 2391746,
    'Mecklenburg-Vorpommern': 1548400,
    'Sachsen': 3914671,
    'Sachsen-Anhalt': 2145671,
    'Thüringen': 2077901,
}
prop_eval = votelib.evaluate.proportional.HighestAverages('sainte_lague')
land_seats = prop_eval.evaluate(land_inhab, 598)
print(land_seats)
{'Nordrhein-Westfalen': 128, 'Bayern': 93, 'Baden-Württemberg': 76, 'Niedersachsen': 59, 'Hessen': 43, 'Sachsen': 32, 'Rheinland-Pfalz': 30, 'Berlin': 24, 'Schleswig-Holstein': 22, 'Brandenburg': 20, 'Sachsen-Anhalt': 17, 'Thüringen': 17, 'Mecklenburg-Vorpommern': 13, 'Hamburg': 12, 'Saarland': 7, 'Bremen': 5}

The seats to parties are distributed proportionally in each federal state according to their shares of second votes, again by the Sainte-Laguë method, eliminating parties that did not reach a 5 % statewide vote threshold:

[5]:
land_prop_eval = votelib.evaluate.core.ByConstituency(    # for each state
    prop_eval,
    apportioner=land_seats,                               # distribute a fixed given amount of seats
    preselector=votelib.evaluate.threshold.RelativeThreshold(Decimal('.05'))
)

In addition to this, the system ensures that the number of seats awarded to each party is proportional to nationwide vote counts. These seat counts are computed by the Sainte-Laguë method. Only parties that gained at least 5 % of the nationwide vote or at least three constituency seats are entitled to national votes:

[6]:
nat_eval = votelib.evaluate.core.Conditioned(
    votelib.evaluate.threshold.AlternativeThresholds([
        votelib.evaluate.threshold.RelativeThreshold(Decimal('.05')),
        votelib.evaluate.threshold.PreviousGainThreshold(
            votelib.evaluate.threshold.AbsoluteThreshold(3)
        )
    ]),
    prop_eval
)

In mixed-member proportional system, overhang seats often arise when a party wins more constituency seats in the first round than it is entitled to by the result of the second round. There are multiple ways to deal with overhang; in Germany, the overhang seats are retained and some more seats (leveling seats) are added to the size of the parliament (AdjustedSeatCount) so that the proportionality with respect to the second round is satisfied in each federal state (LevelOverhangByConstituency).

After the total chamber seat count is determined, the German system specifies that the seats awarded on the national level to each party are to be distributed among that party’s federal state lists in proportion of their votes (ByParty), again by the Sainte-Laguë evaluator (prop_eval).

[7]:
round2_eval = votelib.evaluate.core.PreConverted(
    votelib.convert.ByConstituency(votelib.convert.VoteTotals()),        # sum votes from constituencies to fed states
    votelib.evaluate.core.AdjustedSeatCount(
        calculator=votelib.evaluate.core.LevelOverhangByConstituency(    # computes the actual number of seats
            constituency_evaluator=land_prop_eval,                       # by distributing seats in each fed state
            overall_evaluator=nat_eval,                                  # and comparing to nationwide distribution
        ),
        evaluator=votelib.evaluate.core.ByParty(                         # with the total number of seats
            overall_evaluator=nat_eval,                                  # distribute them among the parties nationally
            allocator=prop_eval,                                         # and distribute those to its fed state lists.
        )
    )
)

Phew. Now we combine the two rounds into a single evaluator using MultistageDistributor (specifying that the votes are doubly nested by depth=2) and specify the normal count of seats (which will then be adjusted by the second round evaluator) to be 598.

[8]:
total_eval = votelib.evaluate.FixedSeatCount(
    votelib.evaluate.core.MultistageDistributor([round1_eval, round2_eval], depth=2),
    598
)

Performing the evaluation

The evaluator produces the seat counts for parties by federal states. We need to provide votes for both stages.

[9]:
land_result = total_eval.evaluate([erst_stimmen, zweit_stimmen])
for party, n_land_seats in land_result['Berlin'].items():
    print(party.ljust(10), n_land_seats)
SPD        5
DIE LINKE  6
CDU        6
GRÜNE      4
AfD        4
FDP        3

To determine the nationwide seat counts, we can use MergedDistributions.

[10]:
bund_result = votelib.convert.MergedDistributions().convert(land_result)
for party, n_land_seats in bund_result.items():
    print(party.ljust(10), n_land_seats)
print()
print('Total     ', sum(bund_result.values()))
SPD        153
CDU        200
AfD        94
FDP        80
DIE LINKE  69
GRÜNE      67
CSU        46

Total      709

We see that seven parties are represented in the Bundestag after 2017, with the number of seats increased to 709 due to a high amount of overhang seats (for CDU and CSU, who won an overwhelming majority of the constituency seats) and leveling seats for other parties to offset that.