Mapping Dutch Democracy: Building a Political Compass from 25,000+ Parliamentary Votes

March 2026 · NLP · SVD · DuckDB · Streamlit · Open StemAtlas ↗

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:

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.

Party and MP positions for 2024-Q4, derived from SVD on voting behavior. Axes are unlabeled — they emerge from the data. Hover over points for names.

When you plot it, the structure is immediately recognizable:

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

YearMotionsNote
2016–2018262Limited historical coverage
20193,374First full year of dense data
20204,228COVID response dominated agenda
20214,289Election year; formateur negotiations
20224,116Rutte IV; energy crisis, nitrogen, Ukraine
2023621Partial year in our dataset
20243,968Schoof cabinet formation and first year
20253,715Continuing high output
2026948Partial 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.

Green bars: % of motions with controversy score ≥ 0.7. White line: average controversy × 100. Both are rising. The dip in 2023 reflects incomplete data (only partial year).

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:

Agreement percentage across all shared votes (all years combined). Green = high agreement, purple = frequent opposition. Diagonal is trivially 100%.

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.

Top 50 MPs/parties by total movement, tracked across 18 quarterly windows. Click legend items to isolate specific actors.

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:

Some directions I'm still exploring:


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.