What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?
That's exactly what this project does. Here's how it works, what the data reveals, and what surprised us along the way.
The Starting Point: Open Data, Hidden Structure
The Dutch Parliament publishes every vote — every motie, every amendement, every besluit — in an open OData API. We're talking over 25,500 motions spanning 2016 to 2026, each with a record of how every MP voted: voor (for), tegen (against), onthouden (abstained), or afwezig (absent).
In raw form this is just a table of votes. The interesting question is: can we extract structure — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom?
The answer is yes, and the method is surprisingly elegant.
Step 1: Turning Votes into Geometry
Each motion is a snapshot of political alignment. We represent party voting behavior with Singular Value Decomposition (SVD) on the party-vote matrix:
- Rows: parties (VVD, PVV, D66, CDA, PvdA, GroenLinks, SP, CU, SGP, FvD, BBB, …)
- Columns: motions
- Values: +1 (voor), -1 (tegen), 0 (absent/abstain)
SVD finds the dominant axes of variation — the directions along which parties disagree most strongly. The first axis almost always corresponds to a left-right spectrum. The second captures something like progressive-traditionalist or libertarian-authoritarian.
We run this per quarterly window so we can track how positions shift over time with fine resolution. The result: coordinates for every party and MP in 2D space, derived purely from voting behavior.
When you plot it, the structure is immediately recognizable:
- Left bloc (PvdA, GroenLinks, SP) cluster tightly together
- Right-liberal (VVD, D66) sit in a distinct quadrant
- Religious right (SGP, CU) form their own coherent group
- Populist right (PVV, FvD) occupy a distant extreme
The political axes emerge from the math — not our intuitions.
Step 2: Semantic Embeddings
Voting patterns tell us who agrees, but not why. For that, we add text embeddings — dense vector representations of each motion's title and description using a language model via OpenRouter. 25,640 motions processed in batches of 200.
This lets us build a meaningful similarity search: two motions are considered "close" only if they're both about a similar topic and produce a similar political split. The fused vector per motion is:
fused = [svd_dims (50)] + [text_dims (2560)] = 2610 dimensions
The similarity cache precomputes the top-20 neighbors per motion per window — 627,272 pairs — making lookup instant at query time.
The Numbers
| Year | Motions | Note |
|---|---|---|
| 2016–2018 | 262 | Limited historical coverage |
| 2019 | 3,374 | First full year of dense data |
| 2020 | 4,228 | COVID response dominated agenda |
| 2021 | 4,289 | Election year; formateur negotiations |
| 2022 | 4,116 | Rutte IV; energy crisis, nitrogen, Ukraine |
| 2023 | 621 | Partial year in our dataset |
| 2024 | 3,968 | Schoof cabinet formation and first year |
| 2025 | 3,715 | Continuing high output |
| 2026 | 948 | Partial year (through March) |
What the Data Actually Shows
Dutch parliament is getting more polarized
Looking at the quarterly controversy scores since 2019, there's a clear upward trend. In 2019, about 30% of motions were highly contested (controversy score ≥ 0.7). By 2024–2025, that figure has risen to over 33%, with the average controversy score up from 0.52 to 0.54.
The trend is not dramatic — the Netherlands isn't polarizing as fast as some other democracies — but it is consistent. Each parliamentary term since 2019 has been marginally more contentious than the last.
Who agrees with whom
Computing the fraction of motions where each party pair votes the same way gives a clear alignment map. Some findings from the data:
- GroenLinks + PvdA: 93.2% — the eventual merger was already written in the voting record years before it happened
- GroenLinks + Volt: 92.4%, PvdA + Volt: 92.1% — the progressive bloc is remarkably tight
- GroenLinks-PvdA + PVV: 39.8% — these two vote together on barely 2 in 5 motions. The most opposed established bloc pair in the chamber.
- FVD + GroenLinks-PvdA: 40.9% — similar level of mutual opposition, consistent across years
Party trajectories reveal political drift
When you trace positions across 18 quarterly windows from 2019 to 2024, clear movement patterns emerge. Some parties are remarkably stable; others shift after elections or coalition changes.
BBB's geometric arrival
BBB (BoerBurgerBeweging) cast its first vote on 2 April 2021 with a single MP after the March 2021 election. Their SVD position immediately placed them between PVV and CDA — agrarian-nationalist populism with Catholic-provincial roots. The model found this without being told. When they won 7 seats in the November 2023 elections, their position shifted only marginally; the geometry was already correct from day one.
SP votes against its own members
The Socialist Party (SP) has the most internal splits of any party: 51 motions where SP members cast votes in both directions. This is far ahead of JA21 (30), BBB (29), and PvdA (22). PVV — despite its populist reputation — has only 4 splits. Wilders runs an unusually tight ship.
The strange case of "Verworpen."
Motions rejected without debate are recorded with the title "Verworpen." (Rejected.). Hundreds of them. Because they share a single 9-character title, their text embeddings are identical — every "Verworpen." has cosine similarity 1.0 to every other "Verworpen." This is technically correct but semantically meaningless. The similarity cache contains these spurious pairs; the UI filters them out.
It's a good reminder that data quality surprises emerge at scale. We have similar issues with "Aangenomen." (Accepted), "Gestaakt." (Tied), and other procedural outcome strings used as motion titles.
The Pipeline
API (Tweede Kamer OData)
→ download_past_year.py
→ motions table (25,500+ rows)
motions
→ extract_mp_votes.py → mp_votes table (450k rows)
→ text_pipeline.py → embeddings table (25,640 rows, via OpenRouter)
→ svd_pipeline.py → svd_vectors table (50,779 rows, quarterly windows)
svd_vectors + embeddings
→ fusion.py → fused_embeddings table (35,872 rows)
fused_embeddings
→ similarity/compute.py → similarity_cache table (627k rows, top-20 per window)
Everything runs locally. The only external call is to OpenRouter for text embeddings. Similarity computation is pure NumPy — cosine similarity matrix multiply, take top-k. For 4,000 motions in a quarter, that's a 4000×4000 matrix computed in seconds.
What's Next
The live explorer is available at stematlas.sgeboers.nl. You can:
- Explore the political compass for any quarter from 2019 to 2025
- Search for motions by topic and find semantically similar past motions
- Trace individual MP trajectories over time
- Browse the full motion database with vote breakdowns
Some directions I'm still exploring:
- Coalition predictor: given a new motion's text, predict likely support based on past patterns
- Controversy classifier: can a model predict whether a motion will be close before the vote?
- Topic modeling: cluster the 25k motions by policy area using the text embeddings, then map which topics are most contested
Reproducibility
# Download historical data
python scripts/download_past_year.py --start-date 2016-01-01 --end-date 2026-01-01
# Run full pipeline
python -m pipeline.run_pipeline --db-path data/motions.db \
--start-date 2016-01-01 --end-date 2026-01-01 \
--window-size quarterly --text-batch-size 200
The DB grows to ~3.6GB for the full dataset. Everything else — SVD, fusion, similarity cache — fits in memory during computation.
Democracy is more legible than it looks.