Parliaments
Metrics computed across a whole chamberFor 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 computedagreement(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'
Where it appears- /parliaments/:id/alignment
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 computedgroup 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)
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 computedgraph = 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
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 computedE[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)
Where it appears- /parliaments/:id/discussion
- /experiments/wordfish
People
Metrics for a single memberEach 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 computedtokens = 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
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 computedagreement(subject, other) = matches / shared_ballots (absent = absent)
keep pairs with shared_ballots ≥ 10 → ranked neighbour list
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 computedclass = paid if harmonised code = paid
unpaid if code ∈ {unpaid, honorary} else de/fr/it text match
else unknown (paid ≻ unpaid ≻ unknown, per organisation)