Skip to main content

Methodology

parlhub does more than list records: it computes voting alignment, party loyalty, lobby networks, word maps and more on top of the open data. This page sets out exactly how each figure is derived and links every metric to the source code that produces it.

Parliaments

Metrics computed across a whole chamber

Voting alignment

For every pair of members who voted in the same ballots, the agreement is the share of shared ballots on which they cast the same vote (yes, no, abstain or absent), where absent matches absent. A member needs at least 20 scored ballots and a pair at least 20 shared ballots to appear. The party heatmap averages these pairwise agreements over each unordered party pair (with a 95 % confidence half-width of 1.96·σ/√n). The scatter turns the agreement matrix into two dimensions by classical multidimensional scaling: distances d = 1 − agreement are double-centred into B, shifted to B + σI (a Gershgorin bound) so it is positive-semidefinite, and the two most-positive eigenvectors give the coordinates. The layout is seeded deterministically, so it never jitters between reloads.

How it's computed
agreement(a,b) = |{ ballots where vote_a = vote_b }| / |shared(a,b)|
distance      d = 1 − agreement
B  = −½ · J · (D∘D) · J            (double-centred squared distances)
B' = B + σ·I,   σ = maxᵢ Σⱼ |Bᵢⱼ|   (Gershgorin PSD shift)
coords = 2 most-positive eigenvectors of B'

Party loyalty & cohesion

For each ballot, a parliamentary group's line is the position (yes, no or abstain) that a strict plurality of its voters took; ties, and groups fielding fewer than 4 voters on that ballot, are not scored. A member dissents whenever their vote differs from their group's line, and their dissent rate is dissents ÷ scored ballots (members with fewer than 10 scored ballots are dropped). The vote-time group is used, falling back to the member's current party where no snapshot exists (e.g. Liechtenstein). Group cohesion is the mean Agreement Index (Hix–Noury–Roland) over the group's ballots: 1 when a group votes as one, 0 when it splits evenly three ways.

How it's computed
group line      = strict plurality of {yes, no, abstain}   (≥ 4 voters, no tie)
dissent_rate(m) = dissents(m) / scored_ballots(m)           (m needs ≥ 10 ballots)
cohesion (AI)   = [ max(Y,N,A) − ½·((Y+N+A) − max(Y,N,A)) ] / (Y+N+A)

Lobby network

Members and the organisations they declare in the register of interests form a bipartite graph; only organisations tied to at least 2 members are kept, so the picture shows shared connections rather than isolated mandates. Each declared tie is classified paid, unpaid or unknown (the harmonised code first, then a German / French / Italian text fallback), and paid outranks unpaid outranks unknown when a member holds several ties to one organisation. The network is laid out with a deterministic Fruchterman–Reingold force simulation: ideal edge length k = √(area/n), repulsion k²/distance, attraction distance²/k, over 300 cooled iterations. No randomness is used, so the graph is stable across reloads.

How it's computed
graph     = members ↔ organisations declared by ≥ 2 members
k         = √(area / n)                    (ideal edge length)
repulsion = k² / distance,   attraction = distance² / k
layout    = 300 Fruchterman–Reingold iterations, cooling ×0.985

Discussion scaling (Wordfish)

Speeches by every active member over a shared time window are tokenised and stopword-filtered per language, then reduced to one word-count document per member. Wordfish (Slapin & Proksch 2008) fits a Poisson model E[y] = exp(α + ψ + β·θ) by alternating Poisson regressions, placing each speaker on a single latent scale θ purely from word choice: α is a speaker's talkativeness, ψ a word's frequency and β how sharply a word discriminates. The fit is hardened (clamped, step-halved, ridge-regularised) and deterministically initialised. η² (the share of the θ variance explained by party) is shown alongside, and the axis uses a robust 2nd–98th percentile domain so that a few thin, high-variance speakers don't set the scale.

How it's computed
E[y_ij] = exp( α_i + ψ_j + β_j · θ_i )      (Wordfish, Poisson)
  θ_i = speaker position    α_i = talkativeness
  ψ_j = word frequency      β_j = word discrimination
η²  = SS_between(party) / SS_total          (θ variance explained by party)

People

Metrics for a single member

Vocabulary (word frequency)

Each of a member's speech transcripts is stripped of HTML, lowercased and split on non-letter characters into tokens longer than one character (accents kept, digits and punctuation dropped). Every token is filtered against the stopword list for that speech's own language, so a bilingual member's German and French speeches are cleaned with the right list. The result is the member's most frequent words (the top 120 with a count of at least 2, over an optional date window), drawn as a squarified treemap sized by count.

How it's computed
tokens = lowercase(strip_html(text)) split on [^\p{L}]+,  length > 1
keep   = tokens NOT IN stopwords[ speech language ]
result = top 120 words with count ≥ 2   →   squarified treemap

Co-voting neighbours

This is the pairwise half of voting alignment, seen from one member: over the ballots where both cast a vote, the agreement with every other member is matches ÷ shared ballots (absent matches absent). Pairs sharing fewer than 10 ballots are hidden as noise, and the list can be re-scoped to a date range. Unlike the parliament scatter, no dimensionality reduction is applied. It is a ranked neighbour list.

How it's computed
agreement(subject, other) = matches / shared_ballots   (absent = absent)
keep pairs with shared_ballots ≥ 10   →   ranked neighbour list
Where it appears
  • /people/:id/alignment

Interest classification

Each register-of-interests entry is sorted into paid, unpaid or unknown: the harmonised payment code is trusted first (unpaid and honorary both count as unpaid), otherwise a German / French / Italian keyword fallback reads the free-text description. Entries are grouped by normalised organisation name to roll a member's mandates up per organisation. This same rule feeds the paid / unpaid split on the lobby network.

How it's computed
class = paid    if harmonised code = paid
        unpaid  if code ∈ {unpaid, honorary}   else de/fr/it text match
        else unknown        (paid ≻ unpaid ≻ unknown, per organisation)

Votings

How one vote is drawn

Voting hemicycle

The seating arc is computed in the browser: rings are added until they hold every voter, the seats are spread across rings by largest-remainder allocation weighted by ring radius, and each seat is placed on its arc and sorted left to right. Coordinates are rounded to two decimals so the server and client render identically and hydration never mismatches. Parties are coloured by descending membership from a fixed palette, and the arch is drawn only when at least 80 % of voters have a known seat.

How it's computed
rings grown until Σ capacity ≥ N voters
seats per ring = largest-remainder allocation weighted by ring radius
coordinates rounded to 2 dp   (identical SSR + client render)

The site itself

Operational metrics

Traffic statistics

Request logs are rolled into immutable daily fact rows (one per period, route, language, visitor class and device) carrying additive counters (requests, summed and maximum latency) and an eight-bucket latency histogram; days older than 90 collapse to month grain. The dashboard re-aggregates these client-side: counts and maxima merge directly, the average is derived as summed latency ÷ requests, and the 95th percentile is approximated from the histogram (the upper edge of the bucket holding the 95th request), because true averages and percentiles cannot be re-merged from stored totals. User agents are classified into visitor class and device once, at roll-up time, so the rules stay revisable.

How it's computed
fact = (period, route, lang, visitor, device) → reqs, sum_ms, max_ms, hist[8]
avg  = sum_ms / reqs
p95  ≈ upper edge of the latency bucket holding the 95th request
Where it appears
  • /project/traffic-stats